├── .codeclimate.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── action_cable_client.gemspec ├── lib ├── action_cable_client.rb └── action_cable_client │ ├── errors.rb │ ├── message.rb │ ├── message_factory.rb │ └── version.rb ├── local_test.rb ├── mesh_test.rb └── spec ├── spec_helper.rb └── unit ├── action_cable_client_spec.rb ├── message_factory_spec.rb └── message_spec.rb /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | # This is a sample .codeclimate.yml configured for Engine analysis on Code 2 | # Climate Platform. For an overview of the Code Climate Platform, see here: 3 | # http://docs.codeclimate.com/article/300-the-codeclimate-platform 4 | 5 | # Under the engines key, you can configure which engines will analyze your repo. 6 | # Each key is an engine name. For each value, you need to specify enabled: true 7 | # to enable the engine as well as any other engines-specific configuration. 8 | 9 | # For more details, see here: 10 | # http://docs.codeclimate.com/article/289-configuring-your-repository-via-codeclimate-yml#platform 11 | 12 | # For a list of all available engines, see here: 13 | # http://docs.codeclimate.com/article/296-engines-available-engines 14 | 15 | engines: 16 | # to turn on an engine, add it here and set enabled to `true` 17 | # to turn off an engine, set enabled to `false` or remove it 18 | rubocop: 19 | enabled: true 20 | # golint: 21 | # enabled: true 22 | # gofmt: 23 | # enabled: true 24 | # eslint: 25 | # enabled: true 26 | # csslint: 27 | # enabled: true 28 | 29 | # Engines can analyze files and report issues on them, but you can separately 30 | # decide which files will receive ratings based on those issues. This is 31 | # specified by path patterns under the ratings key. 32 | 33 | # For more details see here: 34 | # http://docs.codeclimate.com/article/289-configuring-your-repository-via-codeclimate-yml#platform 35 | 36 | # Note: If the ratings key is not specified, this will result in a 0.0 GPA on your dashboard. 37 | 38 | ratings: 39 | paths: 40 | - lib/** 41 | # - app/** 42 | # - "**.rb" 43 | # - "**.go" 44 | 45 | # You can globally exclude files from being analyzed by any engine using the 46 | # exclude_paths key. 47 | 48 | exclude_paths: 49 | - spec/**/* 50 | - vendor/**/* 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | *.gem 3 | *.rbc 4 | /.config 5 | /coverage/ 6 | /InstalledFiles 7 | /pkg/ 8 | /spec/reports/ 9 | /spec/examples.txt 10 | /test/tmp/ 11 | /test/version_tmp/ 12 | /tmp/ 13 | 14 | ## Specific to RubyMotion: 15 | .dat* 16 | .repl_history 17 | build/ 18 | 19 | ## Documentation cache and generated files: 20 | /.yardoc/ 21 | /_yardoc/ 22 | /doc/ 23 | /rdoc/ 24 | 25 | ## Environment normalization: 26 | /.bundle/ 27 | /vendor/bundle 28 | /lib/bundler/man/ 29 | 30 | # for a library or gem, you might want to ignore these files since the code is 31 | # intended to run in multiple environments; otherwise, check them in: 32 | # Gemfile.lock 33 | # .ruby-version 34 | # .ruby-gemset 35 | 36 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 37 | .rvmrc 38 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color --format doc 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.3 3 | Exclude: 4 | - config/initializers/forbidden_yaml.rb 5 | - !ruby/regexp /(vendor|bundle|bin|db|tmp)\/.*/ 6 | DisplayCopNames: true 7 | DisplayStyleGuide: true 8 | 9 | Lint/AssignmentInCondition: 10 | Enabled: false 11 | 12 | Lint/NestedMethodDefinition: 13 | Enabled: false 14 | Exclude: 15 | - test/action_controller/serialization_test.rb 16 | 17 | Style/FrozenStringLiteralComment: 18 | EnforcedStyle: always 19 | 20 | Style/StringLiterals: 21 | EnforcedStyle: single_quotes 22 | 23 | Metrics/AbcSize: 24 | Max: 35 # TODO: Lower to 15 25 | 26 | Metrics/BlockLength: 27 | Exclude: 28 | - spec/**/* 29 | 30 | Metrics/ClassLength: 31 | Max: 261 # TODO: Lower to 100 32 | 33 | Metrics/CyclomaticComplexity: 34 | Max: 7 # TODO: Lower to 6 35 | 36 | Metrics/LineLength: 37 | Max: 110 # TODO: Lower to 80 38 | 39 | Metrics/MethodLength: 40 | Max: 25 41 | 42 | Metrics/PerceivedComplexity: 43 | Max: 9 # TODO: Lower to 7 44 | 45 | Layout/AlignParameters: 46 | EnforcedStyle: with_fixed_indentation 47 | 48 | Style/ClassAndModuleChildren: 49 | EnforcedStyle: nested 50 | 51 | Style/Documentation: 52 | Enabled: false 53 | 54 | Style/DoubleNegation: 55 | Enabled: false 56 | 57 | Style/MissingElse: 58 | Enabled: false # TODO: maybe enable this? 59 | EnforcedStyle: case 60 | 61 | Style/EmptyElse: 62 | EnforcedStyle: empty 63 | 64 | Layout/MultilineOperationIndentation: 65 | EnforcedStyle: indented 66 | 67 | Style/BlockDelimiters: 68 | Enabled: true 69 | EnforcedStyle: line_count_based 70 | 71 | Naming/PredicateName: 72 | Enabled: false # TODO: enable with correct prefixes 73 | 74 | Style/ClassVars: 75 | Enabled: false 76 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | bundler_args: --without guard 3 | rvm: 4 | - 2.3 5 | - 2.4 6 | - 2.5 7 | - 2.6 8 | - 2.7 9 | - ruby-head 10 | script: "bundle exec rspec" 11 | addons: 12 | code_climate: 13 | repo_token: a36151a91a3f70083cbdb99e00dbf75ca91cafb910ad38d0e413c84063872f32 14 | branches: 15 | only: master 16 | notifications: 17 | email: false 18 | 19 | matrix: 20 | fast_finish: true 21 | allow_failures: 22 | - rvm: ruby-head 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 3.0.1 2 | 3 | * [#27](https://github.com/NullVoxPopuli/action_cable_client/pull/27) ([@neomilium](https://github.com/neomilium)) 4 | * Implement reconnect 5 | * Fix issue with subscribing only working on initial connection 6 | * Additional Tests 7 | * Drop support for Ruby 2.2 8 | 9 | ## 2.0.2 10 | 11 | * [#24](https://github.com/NullVoxPopuli/action_cable_client/pull/24) Fix bug where action cable client is too fast for the server and doesn't wait for the server's welcome message before initiating a channel subscription ([@wpp](https://github.com/wpp)) 12 | 13 | ## 2.0.1 14 | 15 | **General** 16 | 17 | * [#22](https://github.com/NullVoxPopuli/action_cable_client/pull/22) Removed ActiveSupport Dependency ([@srabuini](https://github.com/srabuini)) 18 | 19 | ## 2.0 20 | 21 | **General** 22 | 23 | * [#18](https://github.com/NullVoxPopuli/action_cable_client/pull/18) Added the ability to reconnect (@NullVoxPopuli) 24 | * [#19](https://github.com/NullVoxPopuli/action_cable_client/pull/19) Allow for additional params via the identifier (@mcary, @NullVoxPopuli) 25 | * Support ruby-2.4.x 26 | * [#20](https://github.com/NullVoxPopuli/action_cable_client/pull/20) Change underlying websocket gem to [websocket-eventmachine-client](https://github.com/imanel/websocket-eventmachine-client) 27 | * enables SSL 28 | * allows header usage on handshake 29 | 30 | **Breaking** 31 | * [#19](https://github.com/NullVoxPopuli/action_cable_client/pull/19) Removed queued_send in initializer - this allows for a action_cable_client to be simpler, and stay an true to real-time communication as possible -- also it wasn't being used. (@NullVoxPopuli) 32 | * Drop Support for ruby-2.2.x 33 | 34 | ## 1.3.4 35 | * [#7](https://github.com/NullVoxPopuli/action_cable_client/pull/7) Avoid crashing on empty JSON data (@MikeAski) 36 | 37 | ## 1.3.2 38 | * Getting disconnected from the server will now set the result of subscribed? to false (@NullVoxPopuli) 39 | 40 | ## 1.3.0 41 | * subscribed now is a callback instead of a boolean (@NullVoxPopuli) 42 | * subscribed? tells whether or not the client is subscribed to the channel (@NullVoxPopuli) 43 | * added subscribed callback which signifies when the client can start sending messages on the channel (@NullVoxPopuli) 44 | 45 | ## 1.2.4 46 | * [#3](https://github.com/NullVoxPopuli/action_cable_client/pull/3) Support Ruby 2.2.2 (@NullVoxPopuli) 47 | 48 | ## 1.2.3 49 | * The ping message received from the action cable server changed from being identity: \_ping to type: ping (@NullVoxPopuli) 50 | * Fixed an issue where subscribing sometimes didn't work. (@NullVoxPopuli) 51 | 52 | ## 1.2.0 53 | * Made the handling of received messages not all happen in one method. This allows for easier overriding of what is yielded, in case someone wants to also yield the URL for example. (@NullVoxPopuli) 54 | 55 | ## 1.1.0 56 | * Made message queuing optional, off by default. This allows for near-instant message sending (@NullVoxPopuli) 57 | 58 | ## 1.0 59 | * Initial Work (@NullVoxPopuli) 60 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in action_cable_client.gemspec 6 | # gem 'ncursesw', github: 'sup-heliotrope/ncursesw-ruby' 7 | gemspec 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 L. Preston Sego III 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Action Cable Client 2 | [![Gem Version](https://badge.fury.io/rb/action_cable_client.svg)](https://badge.fury.io/rb/action_cable_client) 3 | [![Build Status](https://travis-ci.org/NullVoxPopuli/action_cable_client.svg?branch=master)](https://travis-ci.org/NullVoxPopuli/action_cable_client) 4 | [![Code Climate](https://codeclimate.com/github/NullVoxPopuli/action_cable_client/badges/gpa.svg)](https://codeclimate.com/github/NullVoxPopuli/action_cable_client) 5 | [![Test Coverage](https://codeclimate.com/github/NullVoxPopuli/action_cable_client/badges/coverage.svg)](https://codeclimate.com/github/NullVoxPopuli/action_cable_client/coverage) 6 | 7 | This gem is a wrapper around [websocket-eventmachine-client](https://github.com/imanel/websocket-eventmachine-client), and supports the Rails Action Cable protocol. 8 | 9 | ## Usage 10 | 11 | ```ruby 12 | require 'action_cable_client' 13 | 14 | EventMachine.run do 15 | 16 | uri = "ws://localhost:3000/cable/" 17 | client = ActionCableClient.new(uri, 'RoomChannel') 18 | # called whenever a welcome message is received from the server 19 | client.connected { puts 'successfully connected.' } 20 | 21 | # called whenever a message is received from the server 22 | client.received do | message | 23 | puts message 24 | end 25 | 26 | # Sends a message to the sever, with the 'action', 'speak' 27 | client.perform('speak', { message: 'hello from amc' }) 28 | end 29 | ``` 30 | 31 | This example is compatible with [this version of a small Rails app with Action Cable](https://github.com/NullVoxPopuli/mesh-relay/tree/2ed88928d91d82b88b7878fcb97e3bd81977cfe8) 32 | 33 | 34 | 35 | The available hooks to tie in to are: 36 | - `disconnected {}` 37 | - `connected {}` 38 | - `subscribed {}` 39 | - `rejected {}` 40 | - `errored { |msg| }` 41 | - `received { |msg }` 42 | - `pinged { |msg| }` 43 | 44 | 45 | #### Connecting on initialization is also configurable. 46 | 47 | ```ruby 48 | client = ActionCableClient.new(uri, 'RoomChannel', false) 49 | client.connect!(headers = {}) 50 | ``` 51 | 52 | ```ruby 53 | client.pinged do |_data| 54 | # you could track the time since you last received a ping, if you haven't 55 | # received one in a while, it could be that your client is disconnected. 56 | end 57 | ``` 58 | 59 | 60 | To reconnect, 61 | 62 | ```ruby 63 | client.reconnect! 64 | ``` 65 | 66 | #### Sending additional params 67 | 68 | ```ruby 69 | params = { channel: 'RoomChannel', favorite_color: 'blue' } 70 | client = ActionCableClient.new(uri, params) 71 | ``` 72 | 73 | then on the server end, in your Channel, `params` will give you: 74 | ``` 75 | { 76 | "channel" => "RoomChannel", 77 | "favorite_color" => "blue" 78 | } 79 | ``` 80 | 81 | #### Using Headers 82 | 83 | 84 | ```ruby 85 | params = { channel: 'RoomChannel', favorite_color: 'blue' } 86 | client = ActionCableClient.new(uri, params, true, { 87 | 'Authorization' => 'Bearer token' 88 | }) 89 | ``` 90 | 91 | #### Using TLS 92 | 93 | Example given for client certificate authentication. See EventMachine::Connection#start_tls documentation for other options. 94 | 95 | ```ruby 96 | params = { channel: 'RoomChannel', favorite_color: 'blue' } 97 | tls = {cert_chain_file: 'user.crt', private_key_file: 'user.key'} 98 | client = ActionCableClient.new(uri, params, true, nil, tls) 99 | ``` 100 | 101 | 102 | ## Demo 103 | 104 | [![Live Demo](http://img.youtube.com/vi/x9D1wWsVHMY/mqdefault.jpg)](http://www.youtube.com/watch?v=x9D1wWsVHMY&hd=1) 105 | 106 | Action Cable Client Demo on YouTube (1:41) 107 | 108 | [Here is a set of files in a gist](https://gist.github.com/NullVoxPopuli/edfcbbe91a7877e445cbde84c7f05b37) that demonstrate how different `action_cable_client`s can communicate with eachother. 109 | 110 | ## The Action Cable Protocol 111 | 112 | There really isn't that much to this gem. :-) 113 | 114 | 1. Connect to the Action Cable URL 115 | 2. After the connection succeeds, send a subscribe message 116 | - The subscribe message JSON should look like this 117 | - `{"command":"subscribe","identifier":"{\"channel\":\"MeshRelayChannel\"}"}` 118 | - You should receive a message like this: 119 | - `{"identifier"=>"{\"channel\":\"MeshRelayChannel\"}", "type"=>"confirm_subscription"}` 120 | 3. Once subscribed, you can send messages. 121 | - Make sure that the `action` string matches the data-handling method name on your ActionCable server. 122 | - Your message JSON should look like this: 123 | - `{"command":"message","identifier":"{\"channel\":\"MeshRelayChannel\"}","data":"{\"to\":\"user1\",\"message\":\"hello from user2\",\"action\":\"chat\"}"}` 124 | - Received messages should look about the same 125 | 126 | 4. Notes: 127 | - Every message sent to the server has a `command` and `identifier` key. 128 | - The channel value must match the `name` of the channel class on the ActionCable server. 129 | - `identifier` and `data` are redundantly jsonified. So, for example (in ruby): 130 | ```ruby 131 | payload = { 132 | command: 'command text', 133 | identifier: { channel: 'MeshRelayChannel' }.to_json, 134 | data: { to: 'user', message: 'hi', action: 'chat' }.to_json 135 | }.to_json 136 | ``` 137 | 138 | 139 | ## Contributing 140 | 141 | 1. Fork it ( https://github.com/NullVoxPopuli/action_cable_client/fork ) 142 | 2. Create your feature branch (`git checkout -b my-new-feature`) 143 | 3. Commit your changes (`git commit -am 'Add some feature'`) 144 | 4. Push to the branch (`git push origin my-new-feature`) 145 | 5. Create a new Pull Request 146 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | -------------------------------------------------------------------------------- /action_cable_client.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # allows bundler to use the gemspec for dependencies 4 | lib = File.expand_path('lib', __dir__) 5 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 6 | 7 | require 'action_cable_client/version' 8 | 9 | Gem::Specification.new do |s| 10 | s.name = 'action_cable_client' 11 | s.version = ActionCableClient::VERSION 12 | s.platform = Gem::Platform::RUBY 13 | s.license = 'MIT' 14 | s.authors = ['L. Preston Sego III'] 15 | s.email = 'LPSego3+dev@gmail.com' 16 | s.homepage = 'https://github.com/NullVoxPopuli/action_cable_client' 17 | s.summary = "ActionCableClient-#{ActionCableClient::VERSION}" 18 | s.description = "A ruby client for interacting with Rails' ActionCable" 19 | 20 | s.files = Dir['CHANGELOG.md', 'LICENSE', 'MIT-LICENSE', 'README.md', 'lib/**/*'] 21 | s.require_path = 'lib' 22 | 23 | s.test_files = s.files.grep(%r{^(test|spec|features)/}) 24 | 25 | s.required_ruby_version = '>= 2.3' 26 | 27 | s.add_runtime_dependency 'websocket-eventmachine-client', '>= 1.2.0' 28 | 29 | s.add_development_dependency 'codeclimate-test-reporter' 30 | s.add_development_dependency 'pry-byebug' 31 | s.add_development_dependency 'rspec' 32 | s.add_development_dependency 'rubocop' 33 | end 34 | -------------------------------------------------------------------------------- /lib/action_cable_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # required gems 4 | require 'websocket-eventmachine-client' 5 | require 'forwardable' 6 | require 'json' 7 | 8 | # local files 9 | require 'action_cable_client/errors' 10 | require 'action_cable_client/message_factory' 11 | require 'action_cable_client/message' 12 | 13 | class ActionCableClient 14 | extend Forwardable 15 | 16 | class Commands 17 | SUBSCRIBE = 'subscribe' 18 | MESSAGE = 'message' 19 | end 20 | 21 | attr_reader :_websocket_client, :_uri 22 | attr_reader :_message_factory 23 | # The queue should store entries in the format: 24 | # [ action, data ] 25 | attr_accessor :message_queue, :_subscribed 26 | attr_accessor :_subscribed_callback, :_rejected_callback, :_pinged_callback, :_connected_callback, :_disconnected_callback 27 | 28 | def_delegator :_websocket_client, :onerror, :errored 29 | def_delegator :_websocket_client, :send, :send_msg 30 | 31 | # @param [String] uri - e.g.: ws://domain:port 32 | # @param [String] params - the name of the channel on the Rails server 33 | # or params. This gets sent with every request. 34 | # e.g.: RoomChannel 35 | # @param [Boolean] connect_on_start - connects on init when true 36 | # - otherwise manually call `connect!` 37 | # @param [Hash] headers - HTTP headers to use in the handshake 38 | # @param [Hash] tls - TLS options hash to be passed to EM start_tls 39 | def initialize(uri, params = '', connect_on_start = true, headers = {}, tls = {}) 40 | @_uri = uri 41 | @message_queue = [] 42 | @_subscribed = false 43 | 44 | @_message_factory = MessageFactory.new(params) 45 | 46 | connect!(headers, tls) if connect_on_start 47 | end 48 | 49 | def connect!(headers = {}, tls = {}) 50 | # Quick Reference for WebSocket::EM::Client's api 51 | # - onopen - called after successfully connecting 52 | # - onclose - called after closing connection 53 | # - onmessage - called when client recives a message. on `message do |msg, type (text or binary)|`` 54 | # - also called when a ping is received 55 | # - onerror - called when client encounters an error 56 | # - onping - called when client receives a ping from the server 57 | # - onpong - called when client receives a pong from the server 58 | # - send - sends a message to the server (and also disables any metaprogramming shenanigans :-/) 59 | # - close - closes the connection and optionally sends close frame to server. `close(code, data)` 60 | # - ping - sends a ping 61 | # - pong - sends a pong 62 | @_websocket_client = WebSocket::EventMachine::Client.connect(uri: @_uri, headers: headers, tls: tls) 63 | 64 | @_websocket_client.onclose do 65 | self._subscribed = false 66 | _disconnected_callback&.call 67 | end 68 | end 69 | 70 | def reconnect! 71 | uri = URI(@_uri) 72 | EventMachine.reconnect uri.host, uri.port, @_websocket_client 73 | @_websocket_client.post_init 74 | end 75 | 76 | # @param [String] action - how the message is being sent 77 | # @param [Hash] data - the message to be sent to the channel 78 | def perform(action, data) 79 | dispatch_message(action, data) 80 | end 81 | 82 | # callback for received messages as well as 83 | # what triggers depleting the message queue 84 | # 85 | # @example 86 | # client = ActionCableClient.new(uri, 'RoomChannel') 87 | # client.received do |message| 88 | # # the received message will be JSON 89 | # puts message 90 | # end 91 | def received 92 | _websocket_client.onmessage do |message, _type| 93 | handle_received_message(message) do |json| 94 | yield(json) 95 | end 96 | end 97 | end 98 | 99 | # callback when the client connects to the server 100 | # 101 | # @example 102 | # client = ActionCableClient.new(uri, 'RoomChannel') 103 | # client.connected do 104 | # # do things after the client is connected to the server 105 | # end 106 | def connected 107 | self._connected_callback = proc do |json| 108 | yield(json) 109 | end 110 | end 111 | 112 | # callback when the server rejects the subscription 113 | # 114 | # @example 115 | # client = ActionCableClient.new(uri, 'RoomChannel') 116 | # client.rejected do 117 | # # do things after the server rejects the subscription 118 | # end 119 | def rejected 120 | self._rejected_callback = proc do |json| 121 | yield(json) 122 | end 123 | end 124 | 125 | # callback when the client receives a confirm_subscription message 126 | # from the action_cable server. 127 | # This is only called once, and signifies that you can now send 128 | # messages on the channel 129 | # 130 | # @param [Proc] block - code to run after subscribing to the channel is confirmed 131 | # 132 | # @example 133 | # client = ActionCableClient.new(uri, 'RoomChannel') 134 | # client.connected {} 135 | # client.subscribed do 136 | # # do things after successful subscription confirmation 137 | # end 138 | def subscribed(&block) 139 | self._subscribed_callback = block 140 | end 141 | 142 | # @return [Boolean] is the client subscribed to the channel? 143 | def subscribed? 144 | _subscribed 145 | end 146 | 147 | # callback when the server disconnects from the client. 148 | # 149 | # @example 150 | # client = ActionCableClient.new(uri, 'RoomChannel') 151 | # client.connected {} 152 | # client.disconnected do 153 | # # cleanup after the server disconnects from the client 154 | # end 155 | def disconnected 156 | self._disconnected_callback = proc do 157 | yield 158 | end 159 | end 160 | 161 | def pinged(&block) 162 | self._pinged_callback = block 163 | end 164 | 165 | private 166 | 167 | # @param [String] message - the websockt message object 168 | def handle_received_message(message) 169 | return if message.empty? 170 | 171 | json = JSON.parse(message) 172 | 173 | if is_ping?(json) 174 | _pinged_callback&.call(json) 175 | elsif is_welcome?(json) 176 | subscribe 177 | _connected_callback&.call(json) 178 | elsif is_rejection?(json) 179 | _rejected_callback&.call(json) 180 | elsif !subscribed? 181 | check_for_subscribe_confirmation(json) 182 | else 183 | # TODO: do we want to yield any additional things? 184 | # maybe just make it extensible? 185 | yield(json) 186 | end 187 | end 188 | 189 | # {"identifier" => "_ping","type" => "confirm_subscription"} 190 | def check_for_subscribe_confirmation(message) 191 | message_type = message[Message::TYPE_KEY] 192 | return unless Message::TYPE_CONFIRM_SUBSCRIPTION == message_type 193 | 194 | self._subscribed = true 195 | _subscribed_callback&.call 196 | end 197 | 198 | # {"identifier" => "_ping","message" => 1460201942} 199 | # {"identifier" => "_ping","type" => "confirm_subscription"} 200 | def is_ping?(message) 201 | message_identifier = message[Message::TYPE_KEY] 202 | Message::IDENTIFIER_PING == message_identifier 203 | end 204 | 205 | # {"type" => "welcome"} 206 | def is_welcome?(message) 207 | message_identifier = message[Message::TYPE_KEY] 208 | Message::IDENTIFIER_WELCOME == message_identifier 209 | end 210 | 211 | def is_rejection?(message) 212 | message_type = message[Message::TYPE_KEY] 213 | Message::TYPE_REJECT_SUBSCRIPTION == message_type 214 | end 215 | 216 | def subscribe 217 | msg = _message_factory.create(Commands::SUBSCRIBE) 218 | send_msg(msg.to_json) 219 | end 220 | 221 | def dispatch_message(action, data) 222 | # can't send messages if we aren't subscribed 223 | return unless subscribed? 224 | 225 | msg = _message_factory.create(Commands::MESSAGE, action, data) 226 | json = msg.to_json 227 | send_msg(json) 228 | end 229 | end 230 | -------------------------------------------------------------------------------- /lib/action_cable_client/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActionCableClient 4 | module Errors 5 | class ChannelNotSpecified < StandardError; end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/action_cable_client/message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActionCableClient 4 | class Message 5 | IDENTIFIER_KEY = 'identifier' 6 | IDENTIFIER_PING = 'ping' 7 | IDENTIFIER_WELCOME = 'welcome' 8 | # Type is never sent, but is received 9 | # TODO: find a better place for this constant 10 | TYPE_KEY = 'type' 11 | TYPE_CONFIRM_SUBSCRIPTION = 'confirm_subscription' 12 | TYPE_REJECT_SUBSCRIPTION = 'reject_subscription' 13 | 14 | attr_reader :_command, :_identifier, :_data 15 | 16 | # @param [String] command - the type of message that this is 17 | # @param [Hash] identifier - the channel we are subscribed to 18 | # @param [Hash] data - the data to be sent in this message 19 | def initialize(command, identifier, data) 20 | @_command = command 21 | @_identifier = identifier 22 | @_data = data 23 | end 24 | 25 | def to_json 26 | hash = { 27 | command: _command, 28 | identifier: _identifier.to_json 29 | } 30 | 31 | hash[:data] = _data.to_json if present?(_data) 32 | 33 | hash.to_json 34 | end 35 | 36 | private 37 | 38 | def present?(data) 39 | case data 40 | when String 41 | !(data.empty? || /\A[[:space:]]*\z/.match(data)) 42 | else 43 | data.respond_to?(:empty?) ? !data.empty? : !!data 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/action_cable_client/message_factory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActionCableClient 4 | class MessageFactory 5 | attr_reader :channel, :identifier 6 | 7 | # @param [String or Hash] channel - the name of the subscribed channel, or 8 | # a hash that includes the :channel key and any other params to send. 9 | def initialize(channel) 10 | # the ending result should look like 11 | # "{"channel":"RoomChannel"}" but that's up to 12 | # the Mesage to format it 13 | @channel = channel 14 | @identifier = 15 | case channel 16 | when String then { channel: channel } 17 | when Hash then channel 18 | else 19 | raise ActionCableClient::Errors::ChannelNotSpecified, 'channel is invalid' 20 | end 21 | end 22 | 23 | # @param [String] command - the type of message that this is 24 | # @param [String] action - the action that is performed to send this message 25 | # @param [Hash] message - the data to send 26 | def create(command, action = '', message = nil) 27 | data = build_data(action, message) 28 | Message.new(command, identifier, data) 29 | end 30 | 31 | # @param [String] action - the action that is performed to send this message 32 | # @param [Hash] message - the data to send 33 | # @return [Hash] The data that will be included in the message 34 | def build_data(action, message) 35 | message.merge(action: action) if message.is_a?(Hash) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/action_cable_client/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActionCableClient 4 | VERSION = '3.1.0' 5 | end 6 | -------------------------------------------------------------------------------- /local_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'action_cable_client' 4 | 5 | # this is just a runnable example from the readme 6 | EventMachine.run do 7 | client = ActionCableClient.new('ws://localhost:3001?uid=124', 'MeshRelayChannel') 8 | client.connected { puts 'successfully connected.' } 9 | client.received do |message| 10 | puts message 11 | end 12 | 13 | client.perform('speak', message: 'hello from amc') 14 | end 15 | -------------------------------------------------------------------------------- /mesh_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # require 'action_cable_client' 4 | 5 | current_dir = File.dirname(__FILE__) 6 | # set load path (similar to how gems require files (relative to lib)) 7 | 8 | lib = current_dir + '/lib/' 9 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 10 | 11 | require current_dir + '/lib/action_cable_client' 12 | 13 | class KeyboardHandler < EM::Connection 14 | include EM::Protocols::LineText2 15 | 16 | def initialize(client) 17 | @client = client 18 | end 19 | 20 | def receive_line(data) 21 | @client.perform('chat', message: data, to: '124') 22 | end 23 | end 24 | 25 | # this is just a runnable example from the readme 26 | EventMachine.run do 27 | # client = ActionCableClient.new('ws://mesh-relay-in-us-1.herokuapp.com', 'MeshRelayChannel') 28 | identity = { channel: 'MeshRelayChannel', whatever: 'test params' } 29 | client = ActionCableClient.new('ws://localhost:3000?uid=124', identity) 30 | client.connected { puts 'successfully connected.' } 31 | client.received do |message| 32 | puts client.subscribed? 33 | puts message 34 | end 35 | 36 | client.errored do |*args| 37 | puts 'error' 38 | puts args 39 | end 40 | 41 | client.disconnected do 42 | puts 'disconnected' 43 | end 44 | 45 | EM.open_keyboard(KeyboardHandler, client) 46 | end 47 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubygems' 4 | require 'bundler/setup' 5 | 6 | require 'pry-byebug' # binding.pry to debug! 7 | 8 | # Coverage 9 | ENV['CODECLIMATE_REPO_TOKEN'] = 'a36151a91a3f70083cbdb99e00dbf75ca91cafb910ad38d0e413c84063872f32' 10 | require 'simplecov' 11 | SimpleCov.start 12 | 13 | # This Gem 14 | require 'action_cable_client' 15 | 16 | Dir[File.dirname(__FILE__) + '/support/**/*.rb'].each { |file| require file } 17 | 18 | # This file was generated by the `rspec --init` command. Conventionally, all 19 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 20 | # Require this file using `require "spec_helper"` to ensure that it is only 21 | # loaded once. 22 | # 23 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 24 | RSpec::Expectations.configuration.warn_about_potential_false_positives = false 25 | RSpec.configure do |config| 26 | config.run_all_when_everything_filtered = true 27 | config.filter_run :focus 28 | # Run specs in random order to surface order dependencies. If you find an 29 | # order dependency and want to debug it, you can fix the order by providing 30 | # the seed, which is printed after each run. 31 | # --seed 1234 32 | config.order = 'random' 33 | end 34 | -------------------------------------------------------------------------------- /spec/unit/action_cable_client_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'ostruct' 5 | 6 | describe ActionCableClient do 7 | context 'with empty WebSocketClient' do 8 | let!(:websocket_client) do 9 | websocket_client_class = class_double(WebSocket::EventMachine::Client).as_stubbed_const 10 | websocket_client = instance_double(WebSocket::EventMachine::Client) 11 | 12 | allow(websocket_client_class).to receive(:connect).and_return websocket_client 13 | allow(websocket_client).to receive(:onclose) do |&block| 14 | @websocket_client_onclose_block = block 15 | end 16 | 17 | websocket_client 18 | end 19 | 20 | let(:host) { 'hostname' } 21 | let(:port) { 1234 } 22 | 23 | before(:each) do 24 | @client = ActionCableClient.new("ws://#{host}:#{port}") 25 | allow(@client).to receive(:send_msg) {} 26 | end 27 | 28 | context '#handle_received_message' do 29 | context 'is a ping' do 30 | let(:hash) { { 'type' => 'ping', 'message' => 1_461_845_503 } } 31 | let(:message) { hash.to_json } 32 | it 'nothing is yielded' do 33 | expect do |b| 34 | @client.send(:handle_received_message, message, &b) 35 | end.to_not yield_with_args 36 | end 37 | 38 | it 'calls _pinged_callback' do 39 | result = nil 40 | 41 | @client.pinged do |data| 42 | result = data 43 | end 44 | 45 | @client.send(:handle_received_message, message) 46 | 47 | expect(result).to eq(hash) 48 | end 49 | end 50 | 51 | context 'is not a ping' do 52 | let(:hash) { { 'identifier' => 'notaping', 'type' => 'message' } } 53 | let(:message) { hash.to_json } 54 | 55 | it 'yields whatever' do 56 | expect do |b| 57 | @client._subscribed = true 58 | @client.send(:handle_received_message, message, &b) 59 | end.to yield_with_args(hash) 60 | end 61 | 62 | it 'does not call _pinged_callback' do 63 | expect(@client).to_not receive(:_pinged_callback) 64 | 65 | @client.send(:handle_received_message, message) 66 | end 67 | end 68 | 69 | context 'is a welcome' do 70 | let(:hash) { { 'type' => 'welcome' } } 71 | let(:message) { hash.to_json } 72 | 73 | it 'calls _connected_callback' do 74 | result = nil 75 | 76 | @client.connected do |data| 77 | result = data 78 | end 79 | 80 | @client.send(:handle_received_message, message) 81 | 82 | expect(result).to eq(hash) 83 | end 84 | 85 | it 'subscribes' do 86 | expect(@client).to receive(:subscribe) 87 | 88 | @client.send(:handle_received_message, message) 89 | end 90 | end 91 | 92 | context 'is a rejection' do 93 | let(:hash) { { 'type' => 'reject_subscription' } } 94 | let(:message) { hash.to_json } 95 | 96 | it 'calls _rejected_callback' do 97 | result = nil 98 | 99 | @client.rejected do |data| 100 | result = data 101 | end 102 | 103 | @client.send(:handle_received_message, message) 104 | 105 | expect(result).to eq(hash) 106 | end 107 | end 108 | 109 | context 'empty messages are ignored' do 110 | let(:message) { '' } 111 | 112 | it 'dont yield' do 113 | expect do |b| 114 | @client._subscribed = true 115 | @client.send(:handle_received_message, message, &b) 116 | end.not_to yield_with_args 117 | end 118 | end 119 | end 120 | 121 | context '#perform' do 122 | it 'does not add to the queue' do 123 | @client.perform('action', {}) 124 | expect(@client.message_queue.count).to eq 0 125 | end 126 | 127 | it 'dispatches the message' do 128 | expect(@client).to receive(:dispatch_message) {} 129 | @client.perform('action', {}) 130 | end 131 | end 132 | 133 | context '#dispatch_message' do 134 | it 'does not send if not subscribed' do 135 | @client._subscribed = false 136 | expect(@client).to_not receive(:send_msg) 137 | @client.send(:dispatch_message, 'action', {}) 138 | end 139 | 140 | it 'calls sends when subscribed' do 141 | @client._subscribed = true 142 | expect(@client).to receive(:send_msg) {} 143 | @client.send(:dispatch_message, 'action', {}) 144 | end 145 | end 146 | 147 | context '#subscribe' do 148 | it 'sends a message' do 149 | expect(@client).to receive(:send_msg) {} 150 | @client.send(:subscribe) 151 | end 152 | end 153 | 154 | context '#subscribed' do 155 | it 'sets the callback' do 156 | expect(@client._subscribed_callback).to eq nil 157 | @client.subscribed {} 158 | expect(@client._subscribed_callback).to_not eq nil 159 | end 160 | 161 | it 'once the callback is set, receiving a subscription confirmation invokes the callback' do 162 | callback_called = false 163 | @client.subscribed do 164 | callback_called = true 165 | end 166 | 167 | expect(@client).to receive(:_subscribed_callback).and_call_original 168 | message = { 'identifier' => 'ping', 'type' => 'confirm_subscription' } 169 | @client.send(:check_for_subscribe_confirmation, message) 170 | expect(callback_called).to eq true 171 | end 172 | end 173 | 174 | context '#connected' do 175 | it 'sets the callback' do 176 | expect(@client._connected_callback).to eq(nil) 177 | 178 | @client.connected {} 179 | 180 | expect(@client._connected_callback).to_not eq(nil) 181 | end 182 | end 183 | 184 | context '#disconnected' do 185 | it 'sets subscribed to false' do 186 | @client._subscribed = true 187 | 188 | @websocket_client_onclose_block.call 189 | 190 | expect(@client._subscribed).to be false 191 | end 192 | 193 | it 'sets the callback' do 194 | expect(@client._disconnected_callback).to eq(nil) 195 | 196 | @client.disconnected {} 197 | 198 | expect(@client._disconnected_callback).to_not eq(nil) 199 | end 200 | end 201 | 202 | context '#pinged' do 203 | it 'sets the callback' do 204 | expect(@client._pinged_callback).to eq(nil) 205 | 206 | @client.pinged {} 207 | 208 | expect(@client._pinged_callback).to_not eq(nil) 209 | end 210 | end 211 | 212 | context '#check_for_subscribe_confirmation' do 213 | it 'is a subscribtion confirmation' do 214 | msg = { 'identifier' => '{"channel":"MeshRelayChannel"}', 'type' => 'confirm_subscription' } 215 | @client.send(:check_for_subscribe_confirmation, msg) 216 | expect(@client.subscribed?).to eq true 217 | end 218 | end 219 | 220 | context '#is_ping?' do 221 | it 'is a ping' do 222 | msg = { 'type' => 'ping', 'message' => 1_461_845_611 } 223 | result = @client.send(:is_ping?, msg) 224 | expect(result).to eq true 225 | end 226 | 227 | it 'is not a ping when it is a confirmation' do 228 | msg = { 'identifier' => '{"channel":"MeshRelayChannel"}', 'type' => 'confirm_subscription' } 229 | result = @client.send(:is_ping?, msg) 230 | expect(result).to eq false 231 | end 232 | 233 | it 'is not a ping' do 234 | msg = { 'identifier' => 'notping', 'message' => 1_460_201_942 } 235 | result = @client.send(:is_ping?, msg) 236 | expect(result).to eq false 237 | end 238 | end 239 | 240 | context '#reconnect!' do 241 | before do 242 | allow(EventMachine).to receive(:reconnect) 243 | allow(websocket_client).to receive(:post_init) 244 | end 245 | 246 | it 'asks EventMachine to reconnect on same host and port' do 247 | expect(EventMachine).to receive(:reconnect).with(host, port, websocket_client) 248 | @client.reconnect! 249 | end 250 | 251 | it 'fires EventMachine::WebSocket::Client #post_init' do 252 | # NOTE: in some cases, its required. Have a look to 253 | # https://github.com/imanel/websocket-eventmachine-client/issues/14 254 | # https://github.com/eventmachine/eventmachine/issues/218 255 | expect(websocket_client).to receive(:post_init) 256 | @client.reconnect! 257 | end 258 | end 259 | end 260 | end 261 | -------------------------------------------------------------------------------- /spec/unit/message_factory_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe ActionCableClient::MessageFactory do 6 | it 'initializes with a channel name' do 7 | factory = ActionCableClient::MessageFactory.new('chatroom') 8 | 9 | expect(factory.channel).to eq 'chatroom' 10 | end 11 | 12 | it 'requires a proper channel name' do 13 | expect do 14 | ActionCableClient::MessageFactory.new(nil) 15 | end.to raise_error(ActionCableClient::Errors::ChannelNotSpecified) 16 | end 17 | 18 | context '#build_data' do 19 | it 'returns a constructed hash, given empty message' do 20 | factory = ActionCableClient::MessageFactory.new('chatroom') 21 | result = factory.build_data('hi', {}) 22 | 23 | expected = { action: 'hi' } 24 | expect(result).to eq expected 25 | end 26 | 27 | it 'returns a constructed hash, given a message' do 28 | factory = ActionCableClient::MessageFactory.new('chatroom') 29 | result = factory.build_data('hi', a: 1) 30 | 31 | expected = { action: 'hi', a: 1 } 32 | expect(result).to eq expected 33 | end 34 | end 35 | 36 | it 'builds the identifier based off the channel name' do 37 | factory = ActionCableClient::MessageFactory.new('chatroom') 38 | 39 | expected = { channel: 'chatroom' } 40 | expect(factory.identifier).to eq expected 41 | end 42 | 43 | context '#create' do 44 | it 'creates a message' do 45 | factory = ActionCableClient::MessageFactory.new('chatroom') 46 | 47 | msg = factory.create('message', 'speak', data: 1) 48 | expect(msg).to be_a_kind_of(ActionCableClient::Message) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/unit/message_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe ActionCableClient::Message do 6 | it 'sets the attributes' do 7 | command = 'c', 8 | identfier = {} 9 | data = { d: 2 } 10 | msg = ActionCableClient::Message.new(command, identfier, data) 11 | 12 | expect(msg._command).to eq command 13 | expect(msg._identifier).to eq identfier 14 | expect(msg._data).to eq data 15 | end 16 | 17 | describe '#to_json' do 18 | non_blank_data = [Object.new, true, 0, 1, 'a', [nil], { nil => 0 }, 19 | Time.now] 20 | context 'when data is present' do 21 | non_blank_data.each do |data| 22 | it "double the identifier and the data if the value would be #{data.inspect}" do 23 | command = 'hi' 24 | identifier = { 'hi': 'there' } 25 | 26 | expected = { 27 | command: command, 28 | identifier: identifier.to_json, 29 | data: data.to_json 30 | } 31 | 32 | msg = ActionCableClient::Message.new(command, identifier, data) 33 | 34 | expect(expected.to_json).to eq(msg.to_json) 35 | end 36 | end 37 | end 38 | 39 | context 'when data is not prsent' do 40 | command = 'hi' 41 | identifier = { 'hi': 'there' } 42 | 43 | blank_data = [nil, false, '', ' ', " \n\t \r ", ' ', "\u00a0", [], 44 | {}] 45 | 46 | blank_data.each do |data| 47 | it "does not set :data if the value would be #{data.inspect}" do 48 | expected = { 49 | command: command, 50 | identifier: identifier.to_json 51 | } 52 | 53 | msg = ActionCableClient::Message.new(command, identifier, data) 54 | 55 | expect(expected.to_json).to eq(msg.to_json) 56 | end 57 | end 58 | end 59 | end 60 | end 61 | --------------------------------------------------------------------------------