├── .gitignore ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src ├── examples └── kotlin │ ├── geolocation.kt │ └── twitter-stream.kt ├── main └── kotlin │ ├── api-gson.kt │ ├── api.kt │ ├── closecodes.kt │ ├── outgoing-handler.kt │ ├── socket-factory.kt │ ├── utils.kt │ └── websocket-core.kt └── test └── kotlin ├── closecodetest.kt ├── regular.kt └── test-server.kt /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | .gradle 3 | *.iml 4 | .idea 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kotlin-rxokhttp-websocket [![kotlin-rxokhttp-websocket at gitpack](https://jitpack.io/v/cy6erGn0m/kotlin-rxokhttp-websocket.svg)](https://jitpack.io/#cy6erGn0m/kotlin-rxokhttp-websocket) 2 | 3 | WebSocket library for Kotlin and RxJava/RxKotlin based on OkHttp and Gson 4 | 5 | ## Motivation 6 | Do you even know how terrible it would be programming 7 | websocket on client side to get it async and robust? This is my try to adopt reactive approach to building websocket 8 | clients with RxJava/RxKotlin 9 | 10 | # Getting started 11 | You can include the library using jitpack repo to Gradle and Maven. Follow instructions here https://jitpack.io/#cy6erGn0m/kotlin-rxokhttp-websocket 12 | 13 | ## Examples 14 | 15 | ### WebSocket push example 16 | 17 | ```kotlin 18 | import com.squareup.okhttp.* 19 | import kotlinx.websocket.* 20 | import kotlinx.websocket.gson.* 21 | import rx.lang.kotlin.* 22 | import java.util.concurrent.* 23 | 24 | // class we use to keep location, send and serialize to json 25 | data class GeoLocation(val lat: Double, val lon: Double) 26 | 27 | // here is just dummy geoPositionObservable that produces random coordinates 28 | val geoPositionObservable = 29 | generateSequence { GeoLocation(Math.random(), Math.random()) }.toObservable() 30 | 31 | // create web socket that will check location every 5 seconds and 32 | // send it if location changed since last time 33 | // 34 | // will automatically reconnect if loose connection 35 | val geoPositionWebSocket = OkHttpClient(). 36 | newWebSocket("ws://some-host:8080/ws"). 37 | withGsonProducer( 38 | geoPositionObservable 39 | .sample(5L, TimeUnit.SECONDS) 40 | .distinctUntilChanged()). 41 | open() 42 | ``` 43 | 44 | ### WebSocket pull example 45 | Another example to receive events from server on Twitter stream: 46 | 47 | ```kotlin 48 | import com.squareup.okhttp.* 49 | import kotlinx.websocket.* 50 | import kotlinx.websocket.gson.* 51 | import rx.lang.kotlin.* 52 | 53 | // class we use to keep tweet 54 | data class Tweet( 55 | val user : String, 56 | val login : String, 57 | val text : String, 58 | val tags : List 59 | ) 60 | 61 | // here we can subscribe console logger, UI or something else 62 | val observer = subscriber(). 63 | onNext { tweet -> println(tweet) } 64 | 65 | val twitterWebSocket = OkHttpClient(). 66 | newWebSocket("ws://some-server:8080/ws"). 67 | withGsonConsumer(observer). 68 | open() 69 | ``` 70 | 71 | Of course you can combine both pull and push 72 | 73 | 74 | Enjoy :) 75 | 76 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | jcenter() 4 | } 5 | dependencies { 6 | classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.0.0' 7 | } 8 | } 9 | 10 | apply plugin: 'java' 11 | apply plugin: 'kotlin' 12 | 13 | //sourceCompatibility = 1.8 14 | version = '1.0' 15 | 16 | repositories { 17 | mavenCentral() 18 | maven { url "https://jitpack.io" } 19 | } 20 | 21 | sourceSets { 22 | examples { 23 | compileClasspath += sourceSets.main.runtimeClasspath 24 | } 25 | } 26 | 27 | dependencies { 28 | compile 'org.jetbrains.kotlin:kotlin-stdlib:1.0.0' 29 | compile 'com.google.code.gson:gson:2.3.+' 30 | compile 'com.squareup.okhttp:okhttp-ws:2.3.+' 31 | compile 'com.github.reactivex:rxkotlin:v0.50' 32 | 33 | testCompile 'org.jetbrains.kotlin:kotlin-test-junit:1.0.0' 34 | testCompile 'junit:junit:4.+' 35 | testCompile 'javax.websocket:javax.websocket-api:1.0' 36 | testCompile 'org.eclipse.jetty.websocket:javax-websocket-server-impl:9.2.7.v20150116' 37 | } 38 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cy6erGn0m/kotlin-rxokhttp-websocket/3de426828185bd76f2f0489035d6f269057a1d36/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Mar 18 14:29:35 MSK 2015 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-2.2-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # For Cygwin, ensure paths are in UNIX format before anything is touched. 46 | if $cygwin ; then 47 | [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 48 | fi 49 | 50 | # Attempt to set APP_HOME 51 | # Resolve links: $0 may be a link 52 | PRG="$0" 53 | # Need this for relative symlinks. 54 | while [ -h "$PRG" ] ; do 55 | ls=`ls -ld "$PRG"` 56 | link=`expr "$ls" : '.*-> \(.*\)$'` 57 | if expr "$link" : '/.*' > /dev/null; then 58 | PRG="$link" 59 | else 60 | PRG=`dirname "$PRG"`"/$link" 61 | fi 62 | done 63 | SAVED="`pwd`" 64 | cd "`dirname \"$PRG\"`/" >&- 65 | APP_HOME="`pwd -P`" 66 | cd "$SAVED" >&- 67 | 68 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 69 | 70 | # Determine the Java command to use to start the JVM. 71 | if [ -n "$JAVA_HOME" ] ; then 72 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 73 | # IBM's JDK on AIX uses strange locations for the executables 74 | JAVACMD="$JAVA_HOME/jre/sh/java" 75 | else 76 | JAVACMD="$JAVA_HOME/bin/java" 77 | fi 78 | if [ ! -x "$JAVACMD" ] ; then 79 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 80 | 81 | Please set the JAVA_HOME variable in your environment to match the 82 | location of your Java installation." 83 | fi 84 | else 85 | JAVACMD="java" 86 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 87 | 88 | Please set the JAVA_HOME variable in your environment to match the 89 | location of your Java installation." 90 | fi 91 | 92 | # Increase the maximum file descriptors if we can. 93 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 94 | MAX_FD_LIMIT=`ulimit -H -n` 95 | if [ $? -eq 0 ] ; then 96 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 97 | MAX_FD="$MAX_FD_LIMIT" 98 | fi 99 | ulimit -n $MAX_FD 100 | if [ $? -ne 0 ] ; then 101 | warn "Could not set maximum file descriptor limit: $MAX_FD" 102 | fi 103 | else 104 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 105 | fi 106 | fi 107 | 108 | # For Darwin, add options to specify how the application appears in the dock 109 | if $darwin; then 110 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 111 | fi 112 | 113 | # For Cygwin, switch paths to Windows format before running java 114 | if $cygwin ; then 115 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 116 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 158 | function splitJvmOpts() { 159 | JVM_OPTS=("$@") 160 | } 161 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 162 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 163 | 164 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 165 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'kotlin-rxokhttp' 2 | 3 | -------------------------------------------------------------------------------- /src/examples/kotlin/geolocation.kt: -------------------------------------------------------------------------------- 1 | package jetsocket.examples.geo 2 | 3 | import com.squareup.okhttp.* 4 | import kotlinx.websocket.* 5 | import kotlinx.websocket.gson.* 6 | import rx.lang.kotlin.* 7 | import java.util.concurrent.* 8 | 9 | // class we use to keep location, send and serialize to json 10 | data class GeoLocation(val lat: Double, val lon: Double) 11 | 12 | // here is just dummy geoPositionObservable that produces random coordinates 13 | val geoPositionObservable = 14 | generateSequence { GeoLocation(Math.random(), Math.random()) }.toObservable() 15 | 16 | // create web socket that will check location every 5 seconds and 17 | // send it if location changed since last time 18 | // 19 | // will automatically reconnect if loose connection 20 | val geoPositionWebSocket = OkHttpClient(). 21 | newWebSocket("ws://some-host:8080/ws"). 22 | withGsonProducer( 23 | geoPositionObservable 24 | .sample(5L, TimeUnit.SECONDS) 25 | .distinctUntilChanged()). 26 | open() 27 | -------------------------------------------------------------------------------- /src/examples/kotlin/twitter-stream.kt: -------------------------------------------------------------------------------- 1 | package jetsocket.examples.twitter 2 | 3 | import com.squareup.okhttp.* 4 | import kotlinx.websocket.* 5 | import kotlinx.websocket.gson.* 6 | import rx.lang.kotlin.* 7 | 8 | // class we use to keep tweet 9 | data class Tweet( 10 | val user : String, 11 | val login : String, 12 | val text : String, 13 | val tags : List 14 | ) 15 | 16 | // here we can subscribe console logger, UI or something else 17 | val observer = subscriber(). 18 | onNext { tweet -> println(tweet) } 19 | 20 | val twitterWebSocket = OkHttpClient(). 21 | newWebSocket("ws://some-server:8080/ws"). 22 | withGsonConsumer(observer). 23 | open() 24 | 25 | -------------------------------------------------------------------------------- /src/main/kotlin/api-gson.kt: -------------------------------------------------------------------------------- 1 | package kotlinx.websocket.gson 2 | 3 | import com.google.gson.Gson 4 | import com.squareup.okhttp.ws.WebSocket 5 | import kotlinx.websocket.* 6 | import okio.Buffer 7 | import okio.BufferedSource 8 | import rx.Observable 9 | import rx.Observer 10 | import kotlin.reflect.* 11 | 12 | private fun getGsonDecoder(clazz : KClass, gson : Gson) = { type: WebSocket.PayloadType, buffer: BufferedSource, consumer: Observer -> 13 | when (type) { 14 | WebSocket.PayloadType.TEXT -> consumer.onNext(gson.fromJson(buffer.inputStream().reader(Charsets.UTF_8).use {it.readText()}, clazz.java)) 15 | else -> Unit 16 | } 17 | } 18 | 19 | private fun getGsonEncoder(gson : Gson) = { socket : WebSocket, out : O -> 20 | socket.sendMessage(WebSocket.PayloadType.TEXT, Buffer().writeUtf8(gson.toJson(out))) 21 | } 22 | 23 | inline fun JetSocketBuilder.withGsonConsumer(consumer: Observer, gson : Gson = Gson()): JetSocketBuilderWithReader = 24 | withGsonConsumer(consumer, I::class, gson) 25 | 26 | fun JetSocketBuilder.withGsonConsumer(consumer: Observer, clazz : KClass, gson : Gson = Gson()): JetSocketBuilderWithReader = 27 | with, I, Any> { this.consumer = consumer; this.decoder = getGsonDecoder(clazz, gson) } 28 | 29 | inline fun JetSocketBuilderWithWriter.withGsonConsumer(consumer: Observer, gson : Gson = Gson()): JetSocketDuplex = 30 | withGsonConsumer(consumer, I::class, gson) 31 | 32 | fun JetSocketBuilderWithWriter.withGsonConsumer(consumer: Observer, clazz : KClass, gson : Gson = Gson()): JetSocketDuplex = 33 | with, I, Any> { this.consumer = consumer; this.decoder = getGsonDecoder(clazz, gson) } 34 | 35 | inline fun JetSocketDuplex.withGsonConsumer(consumer: Observer, gson : Gson = Gson()): JetSocketDuplex = 36 | withGsonConsumer(consumer, I::class, gson) 37 | 38 | fun JetSocketDuplex.withGsonConsumer(consumer: Observer, clazz : KClass, gson : Gson = Gson()): JetSocketDuplex = 39 | with, I, Any> { this.consumer = consumer; this.decoder = getGsonDecoder(clazz, gson) } 40 | 41 | fun JetSocketBuilder.withGsonProducer(producer: Observable, gson : Gson = Gson()): JetSocketBuilderWithWriter = 42 | with, Any, O> { this.producer = producer; this.encoder = getGsonEncoder(gson) } 43 | 44 | fun JetSocketBuilderWithReader.withGsonProducer(producer: Observable, gson : Gson = Gson()): JetSocketDuplex = 45 | with, I, O> { this.producer = producer; this.encoder = getGsonEncoder(gson) } 46 | 47 | fun JetSocketDuplex.withGsonProducer(producer: Observable, gson : Gson = Gson()): JetSocketDuplex = 48 | with, I, O> { this.producer = producer; this.encoder = getGsonEncoder(gson) } 49 | -------------------------------------------------------------------------------- /src/main/kotlin/api.kt: -------------------------------------------------------------------------------- 1 | package kotlinx.websocket 2 | 3 | import com.squareup.okhttp.OkHttpClient 4 | import com.squareup.okhttp.Request 5 | import com.squareup.okhttp.ws.WebSocket 6 | import okio.BufferedSource 7 | import rx.Observable 8 | import rx.Observer 9 | import rx.lang.kotlin.PublishSubject 10 | import rx.lang.kotlin.subscriber 11 | import rx.subjects.Subject 12 | import java.io.IOException 13 | import java.util.concurrent.TimeUnit 14 | 15 | enum class WebSocketState { 16 | CREATED, 17 | CONNECTING, 18 | CONNECTED, 19 | CLOSED 20 | } 21 | 22 | interface JetSocketBuilder { 23 | val request: Request 24 | val state: Observer 25 | val reconnectOnEndOfStream : Boolean 26 | val reconnectProvider : (Throwable) -> Observable<*> 27 | } 28 | 29 | interface JetSocketBuilderWithReader : JetSocketBuilder { 30 | val consumer: Observer 31 | val decoder: (WebSocket.PayloadType, BufferedSource, Observer) -> Unit 32 | } 33 | 34 | interface JetSocketBuilderWithWriter : JetSocketBuilder { 35 | val producer: Observable 36 | val encoder: (socket: WebSocket, out: O) -> Unit 37 | } 38 | 39 | interface JetSocketDuplex : JetSocketBuilderWithReader, JetSocketBuilderWithWriter {} 40 | 41 | data class JetSocketInput(val client: OkHttpClient, override val request: Request) : JetSocketBuilder, JetSocketBuilderWithReader, JetSocketBuilderWithWriter { 42 | override var state: Observer = subscriber() 43 | override var reconnectOnEndOfStream : Boolean = true 44 | override var reconnectProvider : (Throwable) -> Observable<*> = {Observable.timer(10L, TimeUnit.SECONDS)} 45 | 46 | override var consumer: Observer = subscriber() 47 | override var decoder: (WebSocket.PayloadType, BufferedSource, Observer) -> Unit = { t, b, o -> } 48 | 49 | override var producer: Observable = PublishSubject() 50 | override var encoder: (socket: WebSocket, out: O) -> Unit = { s, o -> } 51 | } 52 | 53 | @Suppress("UNCHECKED_CAST") 54 | inline fun JetSocketBuilder.with(body: JetSocketInput.() -> Unit): R { 55 | val underlying = this as JetSocketInput 56 | underlying.body() 57 | return underlying as R 58 | } 59 | 60 | fun OkHttpClient.newWebSocket(request: Request): JetSocketBuilder = JetSocketInput(this.ensureConfiguration(), request) 61 | fun OkHttpClient.newWebSocket(url: String, headers: Map = emptyMap()): JetSocketBuilder = newWebSocket( 62 | headers.entries.fold(Request.Builder().url(url)) { acc, e -> acc.addHeader(e.key, e.value) }.build() 63 | ) 64 | 65 | fun JetSocketBuilder.withConsumer(consumer: Observer, decoder: (WebSocket.PayloadType, BufferedSource, Observer) -> Unit): JetSocketBuilderWithReader = 66 | with, I, Any> { this.consumer = consumer; this.decoder = decoder } 67 | 68 | fun JetSocketBuilderWithWriter.withConsumer(consumer: Observer, decoder: (WebSocket.PayloadType, BufferedSource, Observer) -> Unit): JetSocketDuplex = 69 | with, I, Any> { this.consumer = consumer; this.decoder = decoder } 70 | 71 | fun JetSocketDuplex.withConsumer(consumer: Observer, decoder: (WebSocket.PayloadType, BufferedSource, Observer) -> Unit): JetSocketDuplex = 72 | with, I, Any> { this.consumer = consumer; this.decoder = decoder } 73 | 74 | fun JetSocketBuilder.withProducer(producer: Observable, encoder: (socket: WebSocket, out: O) -> Unit): JetSocketBuilderWithWriter = 75 | with, Any, O> { this.producer = producer; this.encoder = encoder } 76 | 77 | fun JetSocketBuilderWithReader.withProducer(producer: Observable, encoder: (socket: WebSocket, out: O) -> Unit): JetSocketDuplex = 78 | with, I, O> { this.producer = producer; this.encoder = encoder } 79 | 80 | fun JetSocketDuplex.withProducer(producer: Observable, encoder: (socket: WebSocket, out: O) -> Unit): JetSocketDuplex = 81 | with, I, O> { this.producer = producer; this.encoder = encoder } 82 | 83 | fun B.withStateObserver(stateObserver: Observer): B = 84 | with{ this.state = stateObserver } 85 | 86 | fun B.withReconnectOnEndOfStream(reconnect : Boolean): B = 87 | with{ this.reconnectOnEndOfStream = reconnect } 88 | 89 | fun B.withReconnectProvider(reconnectProvider : (Throwable) -> Observable<*> ): B = 90 | with{ this.reconnectProvider = reconnectProvider } 91 | 92 | class JetWebSocket { 93 | val closeSubject : Subject = PublishSubject() 94 | } 95 | 96 | data class CloseReason(val closeCode : CloseCode = CloseCodes.NORMAL_CLOSURE, val message : String = "") 97 | val CLOSE_NO_REASON = CloseReason() 98 | 99 | fun JetWebSocket.close(reason : CloseReason = CloseReason()) : Unit = closeSubject.onNext(reason) 100 | 101 | fun WebSocket.safeClose(code: CloseCode = CloseCodes.NORMAL_CLOSURE, message: String = ""): Unit = 102 | try { 103 | close(code.code, message) 104 | } catch(ignore: Throwable) { 105 | } 106 | 107 | 108 | class WebSocketClosedWithReasonIOException(val reason : CloseReason) : IOException("WebSocket closed due to reason ${reason.closeCode.code} ${reason.closeCode.codeName()}: ${reason.message}") 109 | 110 | private fun CloseCode.codeName() = CloseCodes.getCloseCode(this.code).let { resolved -> if (resolved is CloseCodes) resolved.name else "" } -------------------------------------------------------------------------------- /src/main/kotlin/closecodes.kt: -------------------------------------------------------------------------------- 1 | package kotlinx.websocket 2 | 3 | /** 4 | * Copied from CloseReason 5 | */ 6 | interface CloseCode { 7 | val code : Int 8 | } 9 | 10 | fun CloseCode(code : Int) : CloseCode = object: CloseCode { 11 | override val code: Int 12 | get() = code 13 | } 14 | 15 | enum class CloseCodes(override val code: Int) : CloseCode { 16 | /** 17 | * 1000 indicates a normal closure, meaning that the purpose for 18 | * which the connection was established has been fulfilled. 19 | */ 20 | NORMAL_CLOSURE(1000), 21 | /** 22 | * 1001 indicates that an endpoint is "going away", such as a server 23 | * going down or a browser having navigated away from a page. 24 | */ 25 | GOING_AWAY(1001), 26 | /** 27 | * 1002 indicates that an endpoint is terminating the connection due 28 | * to a protocol error. 29 | */ 30 | PROTOCOL_ERROR(1002), 31 | /** 32 | * 1003 indicates that an endpoint is terminating the connection 33 | * because it has received a type of data it cannot accept (e.g., an 34 | * endpoint that understands only text data MAY send this if it 35 | * receives a binary message). 36 | */ 37 | CANNOT_ACCEPT(1003), 38 | /** 39 | * Reserved. The specific meaning might be defined in the future. 40 | */ 41 | RESERVED(1004), 42 | /** 43 | * 1005 is a reserved value and MUST NOT be set as a status code in a 44 | * Close control frame by an endpoint. It is designated for use in 45 | * applications expecting a status code to indicate that no status 46 | * code was actually present. 47 | */ 48 | NO_STATUS_CODE(1005), 49 | /** 50 | * 1006 is a reserved value and MUST NOT be set as a status code in a 51 | * Close control frame by an endpoint. It is designated for use in 52 | * applications expecting a status code to indicate that the 53 | * connection was closed abnormally, e.g., without sending or 54 | * receiving a Close control frame. 55 | */ 56 | CLOSED_ABNORMALLY(1006), 57 | /** 58 | * 1007 indicates that an endpoint is terminating the connection 59 | * because it has received data within a message that was not 60 | * consistent with the type of the message (e.g., non-UTF-8 61 | * data within a text message). 62 | */ 63 | NOT_CONSISTENT(1007), 64 | /** 65 | * 1008 indicates that an endpoint is terminating the connection 66 | * because it has received a message that violates its policy. This 67 | * is a generic status code that can be returned when there is no 68 | * other more suitable status code (e.g., 1003 or 1009) or if there 69 | * is a need to hide specific details about the policy. 70 | */ 71 | VIOLATED_POLICY(1008), 72 | /** 73 | * 1009 indicates that an endpoint is terminating the connection 74 | * because it has received a message that is too big for it to 75 | * process. 76 | */ 77 | TOO_BIG(1009), 78 | /** 79 | * 1010 indicates that an endpoint (client) is terminating the 80 | * connection because it has expected the server to negotiate one or 81 | * more extension, but the server didn't return them in the response 82 | * message of the WebSocket handshake. The list of extensions that 83 | * are needed SHOULD appear in the /reason/ part of the Close frame. 84 | * Note that this status code is not used by the server, because it 85 | * can fail the WebSocket handshake instead. 86 | */ 87 | NO_EXTENSION(1010), 88 | /** 89 | * 1011 indicates that a server is terminating the connection because 90 | * it encountered an unexpected condition that prevented it from 91 | * fulfilling the request. 92 | */ 93 | UNEXPECTED_CONDITION(1011), 94 | /** 95 | * 1012 indicates that the service will be restarted. 96 | */ 97 | SERVICE_RESTART(1012), 98 | /** 99 | * 1013 indicates that the service is experiencing overload 100 | */ 101 | TRY_AGAIN_LATER(1013), 102 | /** 103 | * 1015 is a reserved value and MUST NOT be set as a status code in a 104 | * Close control frame by an endpoint. It is designated for use in 105 | * applications expecting a status code to indicate that the 106 | * connection was closed due to a failure to perform a TLS handshake 107 | * (e.g., the server certificate can't be verified). 108 | */ 109 | TLS_HANDSHAKE_FAILURE(1015); 110 | 111 | companion object { 112 | fun getCloseCode(code : Int) : CloseCode = CloseCodes.values().firstOrNull { it.code == code } ?: CloseCode(code) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/main/kotlin/outgoing-handler.kt: -------------------------------------------------------------------------------- 1 | package kotlinx.websocket 2 | 3 | import com.squareup.okhttp.ws.WebSocket 4 | import rx.Observable 5 | import rx.Observer 6 | import rx.lang.kotlin.subscriber 7 | 8 | 9 | fun subscribeSocket(socket: WebSocket, producer: Observable, closeObserver : Observer, onMessage: (WebSocket, O) -> Unit) = 10 | producer.doOnCompleted { 11 | closeObserver.onNext(CLOSE_NO_REASON) 12 | }.unsafeSubscribe(subscriber().onNext { 13 | onMessage(socket, it) 14 | }.onError { 15 | socket.safeClose() // we expect to receive onFailure in webSocketFactory so we have nothing to do here actually 16 | }) 17 | -------------------------------------------------------------------------------- /src/main/kotlin/socket-factory.kt: -------------------------------------------------------------------------------- 1 | package kotlinx.websocket 2 | 3 | import com.squareup.okhttp.OkHttpClient 4 | import com.squareup.okhttp.Request 5 | import com.squareup.okhttp.Response 6 | import com.squareup.okhttp.ws.WebSocket 7 | import com.squareup.okhttp.ws.WebSocketCall 8 | import com.squareup.okhttp.ws.WebSocketListener 9 | import okio.Buffer 10 | import okio.BufferedSource 11 | import rx.Observer 12 | import rx.lang.kotlin.observable 13 | import java.io.IOException 14 | import java.util.concurrent.TimeUnit 15 | import java.util.concurrent.atomic.AtomicReference 16 | 17 | fun webSocketFactory(httpClient: OkHttpClient, 18 | request: Request, 19 | incoming: Observer, 20 | reconnectOnEndOfStream: Boolean, 21 | pongs: Observer, 22 | messageHandler : (WebSocket.PayloadType, BufferedSource, Observer) -> Unit) = 23 | observable { socketConsumer -> 24 | val configuredClient = httpClient.ensureConfiguration() 25 | 26 | WebSocketCall.create(configuredClient, request).enqueue(object : WebSocketListener { 27 | override fun onOpen(webSocket: WebSocket?, request: Request?, response: Response?) { 28 | if (response?.code() != 101) { 29 | socketConsumer.onError(IOException("Failed to connect to websocket $request due to ${response?.code()} ${response?.message()}")) 30 | } else { 31 | socketConsumer.onNext(webSocket) 32 | } 33 | } 34 | 35 | override fun onPong(payload: Buffer?) { 36 | pongs.onNext(System.currentTimeMillis()) 37 | } 38 | 39 | override fun onClose(code: Int, reason: String?) { 40 | if (reconnectOnEndOfStream) { 41 | socketConsumer.onError(WebSocketClosedWithReasonIOException(CloseReason(CloseCodes.getCloseCode(code), reason ?: ""))) 42 | } else { 43 | socketConsumer.onCompleted() 44 | incoming.onCompleted() 45 | } 46 | } 47 | 48 | override fun onFailure(e: IOException?) { 49 | socketConsumer.onError(e) 50 | } 51 | 52 | override fun onMessage(payload: BufferedSource?, type: WebSocket.PayloadType?) { 53 | if (payload != null && type != null) { 54 | messageHandler(type, payload, incoming) 55 | } 56 | } 57 | }) 58 | } -------------------------------------------------------------------------------- /src/main/kotlin/utils.kt: -------------------------------------------------------------------------------- 1 | package kotlinx.websocket 2 | 3 | import com.squareup.okhttp.OkHttpClient 4 | import rx.Subscription 5 | import java.util.concurrent.TimeUnit 6 | import java.util.concurrent.atomic.AtomicReference 7 | 8 | 9 | fun AtomicReference.subscribed(s: Subscription) = getAndSet(s)?.unsubscribe() 10 | fun AtomicReference.unsubscribe() = getAndSet(null)?.unsubscribe() 11 | 12 | fun Subscription.putTo(reference: AtomicReference) = reference.subscribed(this) 13 | 14 | fun OkHttpClient.ensureConfiguration() : OkHttpClient { 15 | var configuredClient = this 16 | if (this.connectTimeout == 0) { 17 | configuredClient = configuredClient.clone() 18 | configuredClient.setConnectTimeout(15L, TimeUnit.SECONDS) 19 | } 20 | if (this.writeTimeout == 0) { 21 | configuredClient = configuredClient.clone() 22 | configuredClient.setWriteTimeout(10L, TimeUnit.SECONDS) 23 | } 24 | 25 | return configuredClient 26 | } -------------------------------------------------------------------------------- /src/main/kotlin/websocket-core.kt: -------------------------------------------------------------------------------- 1 | package kotlinx.websocket 2 | 3 | import com.squareup.okhttp.ws.WebSocket 4 | import okio.Buffer 5 | import rx.Observable 6 | import rx.Observer 7 | import rx.Subscription 8 | import rx.lang.kotlin.subscriber 9 | import rx.schedulers.Schedulers 10 | import java.util.concurrent.TimeUnit 11 | import java.util.concurrent.atomic.AtomicReference 12 | 13 | fun JetSocketBuilder.open(): JetWebSocket { 14 | state.onNext(WebSocketState.CREATED) 15 | val jetSocket = JetWebSocket() 16 | 17 | with { 18 | val closer = socketCloser() 19 | val pinger = Observable.interval(15L, 10L, TimeUnit.SECONDS).subscribeOn(Schedulers.io()) 20 | val outgoingSubscription = AtomicReference() 21 | val pingerSubscription = AtomicReference() 22 | 23 | jetSocket.closeSubject.subscribeOn(Schedulers.io()).subscribe { 24 | outgoingSubscription.unsubscribe() 25 | pingerSubscription.unsubscribe() 26 | 27 | closer.onCompleted() 28 | consumer.onCompleted() 29 | 30 | state.onNext(WebSocketState.CLOSED) 31 | state.onCompleted() 32 | } 33 | 34 | webSocketFactory(this.client, this.request, consumer, reconnectOnEndOfStream, subscriber(), decoder). 35 | subscribeOn(Schedulers.io()). 36 | doOnSubscribe { state.onNext(WebSocketState.CONNECTING) }. 37 | doOnError { closer.onError(it) }. 38 | doOnCompleted { closer.onCompleted(); state.onNext(WebSocketState.CLOSED); jetSocket.closeSubject.onNext(CLOSE_NO_REASON) }. 39 | retryWhen { it.flatMap(reconnectProvider) }. 40 | subscribe { socket -> 41 | state.onNext(WebSocketState.CONNECTED) 42 | closer.onNext(socket) 43 | subscribeSocket(socket, producer, jetSocket.closeSubject, encoder).putTo(outgoingSubscription) 44 | subscribeSocket(socket, pinger, jetSocket.closeSubject) { s, o -> 45 | s.sendPing(Buffer().writeUtf8("ping")) 46 | }.putTo(pingerSubscription) 47 | } 48 | } 49 | 50 | return jetSocket 51 | } 52 | 53 | private fun socketCloser(): Observer = AtomicReference().let { prev -> 54 | subscriber().onNext { socket -> 55 | prev.getAndSet(socket)?.safeClose() 56 | }.onError { 57 | prev.getAndSet(null)?.safeClose() 58 | }.onCompleted { 59 | prev.getAndSet(null)?.safeClose() 60 | } 61 | } -------------------------------------------------------------------------------- /src/test/kotlin/closecodetest.kt: -------------------------------------------------------------------------------- 1 | package kotlinx.websocket 2 | 3 | import kotlin.test.assertEquals 4 | import kotlin.test.assertNotNull 5 | import org.junit.Test 6 | 7 | class WebSocketTest { 8 | @Test 9 | fun testGeneralCodes() { 10 | val close : CloseCode = CloseCodes.NORMAL_CLOSURE 11 | assertEquals(1000, close.code) 12 | 13 | val custom = object : CloseCode { 14 | override val code: Int 15 | get() = 777 16 | } 17 | 18 | assertEquals(777, custom.code) 19 | } 20 | 21 | @Test 22 | fun testFindCloseCodes() { 23 | val found = CloseCodes.getCloseCode(1000) 24 | assertNotNull(found) 25 | assert(found === CloseCodes.NORMAL_CLOSURE) 26 | } 27 | } -------------------------------------------------------------------------------- /src/test/kotlin/regular.kt: -------------------------------------------------------------------------------- 1 | package kotlinx.websocket.test 2 | 3 | import com.squareup.okhttp.* 4 | import kotlinx.websocket.* 5 | import kotlinx.websocket.gson.* 6 | import org.junit.* 7 | import rx.lang.kotlin.* 8 | import java.util.concurrent.* 9 | import kotlin.test.* 10 | 11 | class RegularTest { 12 | @get:Rule 13 | var server: ServerTestResource = ServerTestResource() 14 | 15 | val allEvents = ReplaySubject>() 16 | 17 | @Before 18 | fun before() { 19 | server.events.subscribe(allEvents) 20 | } 21 | 22 | @Test 23 | fun main() { 24 | var lastState: WebSocketState? = null 25 | val stateObserver = subscriber() 26 | .onNext { 27 | lastState = it 28 | } 29 | 30 | OkHttpClient().newWebSocket("ws://localhost:${server.port}/ws") 31 | .withGsonProducer(listOf(1, 2, 3).toObservable()) 32 | .withStateObserver(stateObserver) 33 | .open() 34 | 35 | val lastEvent = server.events.filter { it.first in listOf("onClose", "onError") }.toBlocking().first() 36 | assertEquals("onClose", lastEvent.first) 37 | assertEquals(WebSocketState.CLOSED, lastState) 38 | 39 | allEvents.onCompleted() 40 | assertEquals(listOf("onOpen" to null, "onMessage" to "1", "onMessage" to "2", "onMessage" to "3", "onClose" to null), allEvents.toList().toBlocking().first()) 41 | } 42 | 43 | @Test 44 | fun receive() { 45 | server.toBeSent = listOf("a", "b", "c", "EOF").toObservable() 46 | 47 | val received = CopyOnWriteArrayList() 48 | val consumer = subscriber() 49 | .onNext { 50 | received.add(it) 51 | } 52 | 53 | val stateObserver = ReplaySubject() 54 | 55 | val socket = OkHttpClient().newWebSocket("ws://localhost:${server.port}/ws") 56 | .withGsonConsumer(consumer, String::class) 57 | .withStateObserver(stateObserver) 58 | .withReconnectOnEndOfStream(false) 59 | .open() 60 | 61 | // stateObserver.filter { it == WebSocketState.CLOSED }.toBlocking().first() 62 | 63 | val lastEvent = server.events.filter { it.first in listOf("onClose", "onError") }.toBlocking().first() 64 | assertEquals("onClose", lastEvent.first) 65 | socket.close() 66 | 67 | println(received) 68 | } 69 | } -------------------------------------------------------------------------------- /src/test/kotlin/test-server.kt: -------------------------------------------------------------------------------- 1 | package kotlinx.websocket.test 2 | 3 | import org.eclipse.jetty.server.* 4 | import org.eclipse.jetty.servlet.* 5 | import org.eclipse.jetty.websocket.jsr356.server.deploy.* 6 | import org.junit.rules.* 7 | import rx.Observable 8 | import rx.lang.kotlin.* 9 | import rx.schedulers.* 10 | import java.net.* 11 | import java.nio.* 12 | import java.util.* 13 | import java.util.concurrent.atomic.* 14 | import java.util.concurrent.locks.* 15 | import javax.websocket.* 16 | import javax.websocket.server.* 17 | import kotlin.concurrent.* 18 | 19 | private val testServerMapper = WeakHashMap() 20 | private val mapperLock = ReentrantLock() 21 | 22 | private fun putResource(container : WebSocketContainer, resource : ServerTestResource) { 23 | mapperLock.withLock { 24 | testServerMapper[container] = resource 25 | } 26 | } 27 | 28 | private fun forgetResource(container : WebSocketContainer) { 29 | mapperLock.withLock { 30 | testServerMapper.remove(container) 31 | } 32 | } 33 | 34 | private fun getResource(container : WebSocketContainer) = mapperLock.withLock { 35 | testServerMapper[container]!! 36 | } 37 | 38 | private fun Session.getResource() = getResource(this.container) 39 | 40 | class ServerTestResource : ExternalResource() { 41 | 42 | var toBeSent : Observable = Observable.empty() 43 | val events = ReplaySubject>() 44 | 45 | val server = AtomicReference() 46 | var port : Int = 0 47 | 48 | private var container : WebSocketContainer? = null 49 | 50 | override fun before() { 51 | super.before() 52 | 53 | port = guessFreePort() 54 | val server = Server(port) 55 | 56 | val handler = ServletContextHandler(ServletContextHandler.SESSIONS) 57 | handler.contextPath = "/" 58 | server.handler = handler 59 | 60 | val wsContainer = WebSocketServerContainerInitializer.configureContext(handler); 61 | wsContainer.addEndpoint(TestServerHandler::class.java) 62 | 63 | server.start() 64 | 65 | container = wsContainer 66 | putResource(wsContainer, this) 67 | 68 | this.server.set(server) 69 | } 70 | 71 | override fun after() { 72 | super.after() 73 | 74 | container?.let { forgetResource(it) } 75 | server.getAndSet(null)?.stop() 76 | events.onCompleted() 77 | } 78 | 79 | private fun guessFreePort() : Int = ServerSocket(0).use { it.localPort } 80 | } 81 | 82 | @ServerEndpoint(value="/ws") 83 | class TestServerHandler { 84 | @OnOpen 85 | fun onConnect(session : Session) { 86 | session.getResource().events.onNext("onOpen" to null) 87 | 88 | session.getResource().toBeSent.subscribeOn(Schedulers.io()).subscribe { 89 | if (it == "EOF") { 90 | session.close(CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "EOF received")) 91 | } else { 92 | session.basicRemote.sendText(it) 93 | } 94 | } 95 | } 96 | 97 | @OnMessage 98 | fun onMessage(session : Session, text : String) { 99 | session.getResource().events.onNext("onMessage" to text) 100 | } 101 | 102 | @OnMessage 103 | fun onMessage(session : Session, bytes : ByteBuffer) { 104 | session.getResource().events.onNext("onMessage" to bytes.toString()) 105 | } 106 | 107 | @OnMessage 108 | fun onPing(session : Session, bytes : PongMessage) { 109 | session.getResource().events.onNext("ping/pong" to null) 110 | } 111 | 112 | @OnClose 113 | fun onClose(session : Session) { 114 | session.getResource().events.onNext("onClose" to null) 115 | } 116 | 117 | @OnError 118 | fun onError(session : Session, error : Throwable) { 119 | session.getResource().events.onNext("onError" to error.toString()) 120 | } 121 | } --------------------------------------------------------------------------------