├── Gemfile ├── .document ├── lib ├── pusher-client │ ├── version.rb │ ├── channels.rb │ ├── channel.rb │ ├── websocket.rb │ └── socket.rb └── pusher-client.rb ├── Rakefile ├── .travis.yml ├── .gitignore ├── examples ├── hello_pusher.rb ├── hello_pusher_ssl.rb ├── hello_pusher_async.rb └── subscribe_private.rb ├── LICENSE.txt ├── pusher-client.gemspec ├── CHANGELOG.md ├── spec ├── spec_helper.rb └── pusherclient_spec.rb └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://www.rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | bin/* 3 | - 4 | features/**/*.feature 5 | LICENSE.txt 6 | -------------------------------------------------------------------------------- /lib/pusher-client/version.rb: -------------------------------------------------------------------------------- 1 | module PusherClient 2 | VERSION = "0.6.2" 3 | end 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | require 'rspec/core/rake_task' 4 | 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | task :default => :spec 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | bundler_args: --without debug 3 | rvm: 4 | - 1.8.7 5 | - 1.9.2 6 | - 1.9.3 7 | - 2.0.0 8 | - 2.1.0 9 | - jruby-18mode 10 | - jruby-19mode 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | coverage 4 | *.swp 5 | *.swn 6 | *.swm 7 | *.swo 8 | mkmf.log 9 | test/rcov/* 10 | .project 11 | .bundle 12 | pkg/* 13 | run_example_playground 14 | examples/hello_pusher_playground.rb 15 | Gemfile.lock 16 | -------------------------------------------------------------------------------- /examples/hello_pusher.rb: -------------------------------------------------------------------------------- 1 | # Usage: $ PUSHER_KEY=YOURKEY ruby examples/hello_pusher.rb 2 | 3 | $:.unshift(File.expand_path("../../lib", __FILE__)) 4 | require 'pusher-client' 5 | require 'pp' 6 | 7 | APP_KEY = ENV['PUSHER_KEY'] # || "YOUR_APPLICATION_KEY" 8 | 9 | socket = PusherClient::Socket.new(APP_KEY) 10 | 11 | # Subscribe to a channel 12 | socket.subscribe('hellopusher') 13 | 14 | # Bind to a channel event 15 | socket['hellopusher'].bind('hello') do |data| 16 | pp data 17 | end 18 | 19 | socket.connect 20 | -------------------------------------------------------------------------------- /lib/pusher-client.rb: -------------------------------------------------------------------------------- 1 | module PusherClient 2 | HOST = 'ws.pusherapp.com' 3 | WS_PORT = 80 4 | WSS_PORT = 443 5 | 6 | def self.logger 7 | @logger ||= begin 8 | require 'logger' 9 | Logger.new(STDOUT) 10 | end 11 | end 12 | 13 | def self.logger=(logger) 14 | @logger = logger 15 | end 16 | end 17 | 18 | require 'pusher-client/version' 19 | require 'pusher-client/websocket' 20 | require 'pusher-client/socket' 21 | require 'pusher-client/channel' 22 | require 'pusher-client/channels' 23 | -------------------------------------------------------------------------------- /examples/hello_pusher_ssl.rb: -------------------------------------------------------------------------------- 1 | # Usage: $ PUSHER_KEY=YOURKEY ruby examples/hello_pusher.rb 2 | 3 | $:.unshift(File.expand_path("../../lib", __FILE__)) 4 | require 'pusher-client' 5 | require 'pp' 6 | 7 | APP_KEY = ENV['PUSHER_KEY'] # || "YOUR_APPLICATION_KEY" 8 | 9 | socket = PusherClient::Socket.new(APP_KEY, { :encrypted => true } ) 10 | 11 | # Subscribe to a channel 12 | socket.subscribe('hellopusher') 13 | 14 | # Bind to a channel event 15 | socket['hellopusher'].bind('hello') do |data| 16 | pp data 17 | end 18 | 19 | socket.connect 20 | -------------------------------------------------------------------------------- /examples/hello_pusher_async.rb: -------------------------------------------------------------------------------- 1 | # Usage: $ PUSHER_KEY=YOURKEY ruby examples/hello_pusher.rb 2 | 3 | $:.unshift(File.expand_path("../../lib", __FILE__)) 4 | require 'pusher-client' 5 | require 'pp' 6 | 7 | APP_KEY = ENV['PUSHER_KEY'] # || "YOUR_APPLICATION_KEY" 8 | 9 | socket = PusherClient::Socket.new(APP_KEY) 10 | socket.connect(true) 11 | 12 | # Subscribe to a channel 13 | socket.subscribe('hellopusher') 14 | 15 | # Bind to a channel event 16 | socket['hellopusher'].bind('hello') do |data| 17 | pp data 18 | end 19 | 20 | loop do 21 | sleep 1 22 | end 23 | -------------------------------------------------------------------------------- /examples/subscribe_private.rb: -------------------------------------------------------------------------------- 1 | # Usage: $ PUSHER_KEY=YOURKEY ruby examples/hello_pusher.rb 2 | 3 | $:.unshift(File.expand_path("../../lib", __FILE__)) 4 | require 'pusher-client' 5 | require 'pp' 6 | 7 | APP_KEY = ENV['PUSHER_KEY'] # || "YOUR_APPLICATION_KEY" 8 | APP_SECRET = ENV['PUSHER_SECRET'] # || "YOUR_APPLICATION_SECRET" 9 | 10 | socket = PusherClient::Socket.new(APP_KEY, { :encrypted => true, :secret => APP_SECRET } ) 11 | 12 | # Subscribe to a channel 13 | socket.subscribe('private-hellopusher') 14 | 15 | # Bind to a channel event 16 | socket['hellopusher'].bind('hello') do |data| 17 | pp data 18 | end 19 | 20 | socket.connect 21 | -------------------------------------------------------------------------------- /lib/pusher-client/channels.rb: -------------------------------------------------------------------------------- 1 | module PusherClient 2 | class Channels 3 | 4 | attr_reader :channels 5 | 6 | def initialize(logger=PusherClient.logger) 7 | @logger = logger 8 | @channels = {} 9 | end 10 | 11 | def add(channel_name, user_data=nil) 12 | @channels[channel_name] ||= Channel.new(channel_name, user_data, @logger) 13 | end 14 | 15 | def find(channel_name) 16 | @channels[channel_name] 17 | end 18 | 19 | def remove(channel_name) 20 | @channels.delete(channel_name) 21 | end 22 | 23 | def empty? 24 | @channels.empty? 25 | end 26 | 27 | def size 28 | @channels.size 29 | end 30 | 31 | alias :<< :add 32 | alias :[] :find 33 | 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Logan Koester 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /pusher-client.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'pusher-client/version' 5 | 6 | Gem::Specification.new do |s| 7 | s.name = 'pusher-client' 8 | s.version = PusherClient::VERSION 9 | s.authors = ["Pusher", "Logan Koester"] 10 | s.email = ['support@pusher.com'] 11 | s.homepage = 'http://github.com/pusher/pusher-ruby-client' 12 | s.summary = 'Client for consuming WebSockets from http://pusher.com' 13 | s.description = 'Client for consuming WebSockets from http://pusher.com' 14 | 15 | s.files = `git ls-files`.split("\n") 16 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 17 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| 18 | File.basename(f) 19 | } 20 | s.extra_rdoc_files = %w(LICENSE.txt README.rdoc) 21 | s.require_paths = ['lib'] 22 | s.licenses = ['MIT'] 23 | 24 | s.add_runtime_dependency 'websocket', '~> 1.0' 25 | s.add_runtime_dependency 'json' 26 | 27 | s.add_development_dependency "rspec" 28 | s.add_development_dependency "rake" 29 | s.add_development_dependency "bundler" 30 | end 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 0.6.2 / 2015-05-14 3 | ================== 4 | 5 | * DOC FIX: Add secure hash key in addition to encrypted 6 | * FIX: Make sure to read all messages received 7 | * FIX: Don't hide IOError when reading from the socket 8 | 9 | 0.6.1 / 2015-05-14 10 | ================== 11 | 12 | Same release as above but with missing changelog 13 | 14 | 0.6.0 / 2014-04-23 15 | ================== 16 | 17 | * options[:auth_method] for both private and presence channels 18 | * Relax websocket dependency to any 1.x version 19 | 20 | 0.5.0 / 2014-04-15 21 | ================== 22 | 23 | * Updating to protocol v6 24 | * Fixes scope issues with user_data 25 | * Makes missing user_data on presence channel error explicit 26 | * Mask outgoing data. 27 | * Update the websocket dependency to 1.1.2 28 | * Allow to use app_key that are symbols 29 | * Allow to specify the logger when creating a new Socket 30 | * Don't set Thread.abort_on_exception = true 31 | * Capture Thread exceptions when running async 32 | * Not raising an ArgumentError had slipped through the cracks. The test case exist. 33 | * Retain a consistent code style with the rest of the code and ruby's unofficial styling. 34 | * Add send_channel_event method on socket for client channel events 35 | 36 | 0.4.0 and previous not documented :/ 37 | 38 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path('../lib', __FILE__) 2 | 3 | require 'pusher-client' 4 | 5 | require 'logger' 6 | 7 | TEST_APP_KEY = "TEST_APP_KEY" 8 | 9 | module PusherClient 10 | class TestLogger < Logger 11 | attr_reader :test_messages 12 | 13 | def initialize(logdev, shift_age = 0, shift_size = 1048576) 14 | @test_messages = [] 15 | super 16 | end 17 | def test(msg) 18 | @test_messages << msg 19 | debug msg 20 | end 21 | end 22 | 23 | class Socket 24 | # Simulate a connection being established 25 | def connect(async = false) 26 | @connection_thread = Thread.new do 27 | @connection = TestConnection.new 28 | @global_channel.dispatch('pusher:connection_established', JSON.dump({'socket_id' => '123abc'})) 29 | end 30 | @connection_thread.run 31 | @connection_thread.join unless async 32 | return self 33 | end 34 | 35 | def simulate_received(event_name, event_data, channel_name) 36 | send_local_event(event_name, event_data, channel_name) 37 | end 38 | end 39 | 40 | class TestConnection 41 | def send(payload) 42 | PusherClient.logger.test("SEND: #{payload}") 43 | end 44 | 45 | def close 46 | end 47 | end 48 | 49 | PusherClient.logger = TestLogger.new('test.log') 50 | 51 | end 52 | -------------------------------------------------------------------------------- /lib/pusher-client/channel.rb: -------------------------------------------------------------------------------- 1 | module PusherClient 2 | 3 | class Channel 4 | attr_accessor :global, :subscribed 5 | attr_reader :name, :callbacks, :user_data 6 | 7 | def initialize(channel_name, user_data=nil, logger=PusherClient.logger) 8 | @name = channel_name 9 | @user_data = user_data 10 | @logger = logger 11 | @global = false 12 | @callbacks = {} 13 | @subscribed = false 14 | end 15 | 16 | def bind(event_name, &callback) 17 | PusherClient.logger.debug "Binding #{event_name} to #{name}" 18 | @callbacks[event_name] = callbacks[event_name] || [] 19 | @callbacks[event_name] << callback 20 | return self 21 | end 22 | 23 | def dispatch_with_all(event_name, data) 24 | dispatch(event_name, data) 25 | end 26 | 27 | def dispatch(event_name, data) 28 | logger.debug("Dispatching #{global ? 'global ' : ''}callbacks for #{event_name}") 29 | if @callbacks[event_name] 30 | @callbacks[event_name].each do |callback| 31 | callback.call(data) 32 | end 33 | else 34 | logger.debug "No #{global ? 'global ' : ''}callbacks to dispatch for #{event_name}" 35 | end 36 | end 37 | 38 | def acknowledge_subscription(data) 39 | @subscribed = true 40 | end 41 | 42 | private 43 | 44 | attr_reader :logger 45 | end 46 | 47 | class NullChannel 48 | def initialize(channel_name, *a) 49 | @name = channel_name 50 | end 51 | def method_missing(*a) 52 | raise ArgumentError, "Channel `#{@name}` hasn't been subscribed yet." 53 | end 54 | end 55 | 56 | end 57 | -------------------------------------------------------------------------------- /lib/pusher-client/websocket.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require 'websocket' 3 | require 'openssl' 4 | 5 | module PusherClient 6 | class PusherWebSocket 7 | WAIT_EXCEPTIONS = [Errno::EAGAIN, Errno::EWOULDBLOCK] 8 | WAIT_EXCEPTIONS << IO::WaitReadable if defined?(IO::WaitReadable) 9 | 10 | CA_FILE = File.expand_path('../../../certs/cacert.pem', __FILE__) 11 | 12 | attr_accessor :socket 13 | 14 | def initialize(url, params = {}) 15 | @hs ||= WebSocket::Handshake::Client.new(:url => url) 16 | @frame ||= WebSocket::Frame::Incoming::Server.new(:version => @hs.version) 17 | @socket = TCPSocket.new(@hs.host, @hs.port || 80) 18 | @cert_file = params[:cert_file] 19 | @logger = params[:logger] || PusherClient.logger 20 | 21 | if params[:ssl] == true 22 | ctx = OpenSSL::SSL::SSLContext.new 23 | if params[:ssl_verify] 24 | ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER|OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT 25 | # http://curl.haxx.se/ca/cacert.pem 26 | ctx.ca_file = @cert_file || CA_FILE 27 | else 28 | ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE 29 | end 30 | 31 | ssl_sock = OpenSSL::SSL::SSLSocket.new(@socket, ctx) 32 | ssl_sock.sync_close = true 33 | ssl_sock.connect 34 | 35 | @socket = ssl_sock 36 | end 37 | 38 | @socket.write(@hs.to_s) 39 | @socket.flush 40 | 41 | loop do 42 | data = @socket.getc 43 | next if data.nil? 44 | 45 | @hs << data 46 | 47 | if @hs.finished? 48 | raise @hs.error.to_s unless @hs.valid? 49 | @handshaked = true 50 | break 51 | end 52 | end 53 | end 54 | 55 | def send(data, type = :text) 56 | raise "no handshake!" unless @handshaked 57 | 58 | data = WebSocket::Frame::Outgoing::Client.new( 59 | :version => @hs.version, 60 | :data => data, 61 | :type => type 62 | ).to_s 63 | @socket.write data 64 | @socket.flush 65 | end 66 | 67 | def receive 68 | raise "no handshake!" unless @handshaked 69 | 70 | begin 71 | data = @socket.read_nonblock(1024) 72 | rescue *WAIT_EXCEPTIONS 73 | IO.select([@socket]) 74 | retry 75 | end 76 | @frame << data 77 | 78 | messages = [] 79 | while message = @frame.next 80 | if message.type === :ping 81 | send(message.data, :pong) 82 | return messages 83 | end 84 | messages << message.to_s 85 | end 86 | messages 87 | end 88 | 89 | def close 90 | @socket.close 91 | rescue IOError => error 92 | logger.debug error.message 93 | end 94 | 95 | private 96 | 97 | attr_reader :logger 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pusher-client: Ruby WebSocket client for Pusher Channels 2 | 3 | [![Build Status](https://secure.travis-ci.org/pusher/pusher-websocket-ruby.svg?branch=master)](http://travis-ci.org/pusher/pusher-websocket-ruby) 4 | 5 | `pusher-client` is a Ruby gem for consuming WebSockets from the [Pusher Channels](https://pusher.com/channels) web service. 6 | 7 | ## Installation 8 | 9 | ```sh 10 | gem install pusher-client 11 | ``` 12 | 13 | This gem is compatible with jruby since 0.2. 14 | 15 | ## Single-Threaded Usage 16 | 17 | The application will pause at `channels_client.connect` and handle events from Pusher Channels as they happen. 18 | 19 | ```ruby 20 | require 'pusher-client' 21 | cluster = 'mt1' # take this from your app's config in the dashboard 22 | channels_client = PusherClient::Socket.new(YOUR_APPLICATION_KEY, { 23 | secure: true, 24 | ws_host: "ws-#{cluster}.pusher.com" 25 | }) 26 | 27 | # Subscribe to two channels 28 | channels_client.subscribe('channel1') 29 | channels_client.subscribe('channel2') 30 | 31 | # Subscribe to presence channel 32 | channels_client.subscribe('presence-channel3', USER_ID) 33 | 34 | # Subscribe to private channel 35 | channels_client.subscribe('private-channel4', USER_ID) 36 | 37 | # Subscribe to presence channel with custom data (user_id is mandatory) 38 | channels_client.subscribe('presence-channel5', :user_id => USER_ID, :user_name => 'john') 39 | 40 | # Bind to a global event (can occur on either channel1 or channel2) 41 | channels_client.bind('globalevent') do |data| 42 | puts data 43 | end 44 | 45 | # Bind to a channel event (can only occur on channel1) 46 | channels_client['channel1'].bind('channelevent') do |data| 47 | puts data 48 | end 49 | 50 | channels_client.connect 51 | ``` 52 | 53 | ## Asynchronous Usage 54 | 55 | With `channels_client.connect(true)`, 56 | the connection to Pusher Channels will be maintained in its own thread. 57 | The connection will remain open in the background as long as your main application thread is running, 58 | and you can continue to subscribe/unsubscribe to channels and bind new events. 59 | 60 | ```ruby 61 | require 'pusher-client' 62 | channels_client = PusherClient::Socket.new(YOUR_APPLICATION_KEY) 63 | channels_client.connect(true) # Connect asynchronously 64 | 65 | # Subscribe to two channels 66 | channels_client.subscribe('channel1') 67 | channels_client.subscribe('channel2') 68 | 69 | # Bind to a global event (can occur on either channel1 or channel2) 70 | channels_client.bind('globalevent') do |data| 71 | puts data 72 | end 73 | 74 | # Bind to a channel event (can only occur on channel1) 75 | channels_client['channel1'].bind('channelevent') do |data| 76 | puts data 77 | end 78 | 79 | loop do 80 | sleep(1) # Keep your main thread running 81 | end 82 | ``` 83 | 84 | ## Using native WebSocket implementation 85 | 86 | This gem depends on [the `websocket` gem](https://github.com/imanel/websocket-ruby) 87 | which is a pure Ruby implementation of websockets. 88 | 89 | However it can optionally use a native C or Java implementation for a 25% speed 90 | increase by including [the `websocket-native` gem](https://github.com/imanel/websocket-ruby-native) in your Gemfile. 91 | 92 | ## Copyright and license 93 | 94 | See `LICENSE.txt`. 95 | -------------------------------------------------------------------------------- /spec/pusherclient_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "A PusherClient::Channels collection" do 4 | before do 5 | @channels = PusherClient::Channels.new 6 | end 7 | 8 | it "should initialize empty" do 9 | expect(@channels).to be_empty 10 | expect(@channels.size).to eq(0) 11 | end 12 | 13 | it "should instantiate new channels added to it by name" do 14 | @channels << 'TestChannel' 15 | expect(@channels.find('TestChannel').class).to eq(PusherClient::Channel) 16 | end 17 | 18 | it "should allow removal of channels by name" do 19 | @channels << 'TestChannel' 20 | expect(@channels['TestChannel'].class).to eq(PusherClient::Channel) 21 | @channels.remove('TestChannel') 22 | expect(@channels).to be_empty 23 | end 24 | 25 | it "should not allow two channels of the same name" do 26 | @channels << 'TestChannel' 27 | @channels << 'TestChannel' 28 | expect(@channels.size).to eq(1) 29 | end 30 | 31 | end 32 | 33 | describe "A PusherClient::Channel" do 34 | before do 35 | @channels = PusherClient::Channels.new 36 | @channel = @channels << "TestChannel" 37 | end 38 | 39 | it 'should not be subscribed by default' do 40 | expect(@channel.subscribed).to be_falsey 41 | end 42 | 43 | it 'should not be global by default' do 44 | expect(@channel.global).to be_falsey 45 | end 46 | 47 | it 'can have procs bound to an event' do 48 | @channel.bind('TestEvent') {} 49 | expect(@channel.callbacks.size).to eq(1) 50 | end 51 | 52 | it 'should run callbacks when an event is dispatched' do 53 | 54 | @channel.bind('TestEvent') do 55 | PusherClient.logger.test "Local callback running" 56 | end 57 | 58 | @channel.dispatch('TestEvent', {}) 59 | expect(PusherClient.logger.test_messages).to include("Local callback running") 60 | end 61 | 62 | end 63 | 64 | describe "A PusherClient::Socket" do 65 | before do 66 | @socket = PusherClient::Socket.new(TEST_APP_KEY, :secret => 'secret') 67 | end 68 | 69 | it 'should not connect when instantiated' do 70 | expect(@socket.connected).to be_falsey 71 | end 72 | 73 | it 'should raise ArgumentError if TEST_APP_KEY is an empty string' do 74 | expect { 75 | @broken_socket = PusherClient::Socket.new('') 76 | }.to raise_error(ArgumentError) 77 | expect { 78 | @broken_socket = PusherClient::Socket.new(nil) 79 | }.to raise_error(ArgumentError) 80 | end 81 | 82 | describe "...when connected" do 83 | before do 84 | @socket.connect 85 | end 86 | 87 | it 'should know its connected' do 88 | expect(@socket.connected).to be_truthy 89 | end 90 | 91 | it 'should know its socket_id' do 92 | expect(@socket.socket_id).to eq('123abc') 93 | end 94 | 95 | it 'should not be subscribed to its global channel' do 96 | expect(@socket.global_channel.subscribed).to be_falsey 97 | end 98 | 99 | it 'should subscribe to a channel' do 100 | @channel = @socket.subscribe('testchannel') 101 | expect(@socket.channels['testchannel']).to eq(@channel) 102 | expect(@channel.subscribed).to be_truthy 103 | end 104 | 105 | it 'should unsubscribe from a channel' do 106 | @socket.subscribe('testchannel') 107 | @socket.unsubscribe('testchannel') 108 | expect(PusherClient.logger.test_messages.last).to include('pusher:unsubscribe') 109 | expect(@socket.channels['testchannel']).to be_nil 110 | end 111 | 112 | it 'should subscribe to a private channel' do 113 | @channel = @socket.subscribe('private-testchannel') 114 | expect(@socket.channels['private-testchannel']).to eq(@channel) 115 | expect(@channel.subscribed).to be_truthy 116 | end 117 | 118 | it 'should subscribe to a presence channel with user_id' do 119 | @channel = @socket.subscribe('presence-testchannel', '123') 120 | expect(@socket.channels['presence-testchannel']).to eq(@channel) 121 | expect(@channel.user_data).to eq('{"user_id":"123"}') 122 | expect(@channel.subscribed).to be_truthy 123 | end 124 | 125 | it 'should subscribe to a presence channel with custom channel_data' do 126 | @channel = @socket.subscribe('presence-testchannel', :user_id => '123', :user_name => 'john') 127 | expect(@socket.channels['presence-testchannel']).to eq(@channel) 128 | expect(@channel.user_data).to eq('{"user_id":"123","user_name":"john"}') 129 | expect(@channel.subscribed).to be_truthy 130 | end 131 | 132 | it 'should allow binding of global events' do 133 | @socket.bind('testevent') { |data| PusherClient.logger.test("testchannel received #{data}") } 134 | expect(@socket.global_channel.callbacks.has_key?('testevent')).to be_truthy 135 | end 136 | 137 | it 'should trigger callbacks for global events' do 138 | @socket.bind('globalevent') { |data| PusherClient.logger.test("Global event!") } 139 | expect(@socket.global_channel.callbacks.has_key?('globalevent')).to be_truthy 140 | 141 | @socket.simulate_received('globalevent', 'some data', '') 142 | expect(PusherClient.logger.test_messages.last).to include('Global event!') 143 | end 144 | 145 | it 'should kill the connection thread when disconnect is called' do 146 | @socket.disconnect 147 | expect(Thread.list.size).to eq(1) 148 | end 149 | 150 | it 'should not be connected after disconnecting' do 151 | @socket.disconnect 152 | expect(@socket.connected).to be_falsey 153 | end 154 | 155 | describe "when subscribed to a channel" do 156 | before do 157 | @channel = @socket.subscribe('testchannel') 158 | end 159 | 160 | it 'should allow binding of callbacks for the subscribed channel' do 161 | @socket['testchannel'].bind('testevent') { |data| PusherClient.logger.test(data) } 162 | expect(@socket['testchannel'].callbacks.has_key?('testevent')).to be_truthy 163 | end 164 | 165 | it "should trigger channel callbacks when a message is received" do 166 | # Bind 2 events for the channel 167 | @socket['testchannel'].bind('coming') { |data| PusherClient.logger.test(data) } 168 | @socket['testchannel'].bind('going') { |data| PusherClient.logger.test(data) } 169 | 170 | # Simulate the first event 171 | @socket.simulate_received('coming', 'Hello!', 'testchannel') 172 | expect(PusherClient.logger.test_messages.last).to include('Hello!') 173 | 174 | # Simulate the second event 175 | @socket.simulate_received('going', 'Goodbye!', 'testchannel') 176 | expect(PusherClient.logger.test_messages.last).to include('Goodbye!') 177 | end 178 | 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /lib/pusher-client/socket.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'openssl' 3 | require 'digest/md5' 4 | 5 | module PusherClient 6 | class Socket 7 | 8 | CLIENT_ID = 'pusher-ruby-client' 9 | PROTOCOL = '6' 10 | 11 | attr_reader :path, :connected, :channels, :global_channel, :socket_id 12 | 13 | def initialize(app_key, options={}) 14 | raise ArgumentError, "Missing app_key" if app_key.to_s.empty? 15 | 16 | @path = "#{options[:ws_path]}/app/#{app_key}?client=#{CLIENT_ID}&version=#{PusherClient::VERSION}&protocol=#{PROTOCOL}" 17 | @key = app_key.to_s 18 | @secret = options[:secret] 19 | @socket_id = nil 20 | @logger = options[:logger] || PusherClient.logger 21 | @channels = Channels.new(@logger) 22 | @global_channel = Channel.new('pusher_global_channel') 23 | @global_channel.global = true 24 | @connected = false 25 | @encrypted = options[:encrypted] || options[:secure] || false 26 | # :private_auth_method is deprecated 27 | @auth_method = options[:auth_method] || options[:private_auth_method] 28 | @cert_file = options[:cert_file] 29 | @ws_host = options[:ws_host] || HOST 30 | @ws_port = options[:ws_port] || WS_PORT 31 | @wss_port = options[:wss_port] || WSS_PORT 32 | @ssl_verify = options.fetch(:ssl_verify, true) 33 | 34 | if @encrypted 35 | @url = "wss://#{@ws_host}:#{@wss_port}#{@path}" 36 | else 37 | @url = "ws://#{@ws_host}:#{@ws_port}#{@path}" 38 | end 39 | 40 | bind('pusher:connection_established') do |data| 41 | socket = parser(data) 42 | @connected = true 43 | @socket_id = socket['socket_id'] 44 | subscribe_all 45 | end 46 | 47 | bind('pusher:connection_disconnected') do |data| 48 | @connected = false 49 | @channels.channels.each { |c| c.disconnect } 50 | end 51 | 52 | bind('pusher:error') do |data| 53 | logger.fatal("Pusher : error : #{data.inspect}") 54 | end 55 | 56 | # Keep this in case we're using a websocket protocol that doesn't 57 | # implement ping/pong 58 | bind('pusher:ping') do 59 | send_event('pusher:pong', nil) 60 | end 61 | end 62 | 63 | def connect(async = false) 64 | return if @connection 65 | logger.debug("Pusher : connecting : #{@url}") 66 | 67 | if async 68 | @connection_thread = Thread.new do 69 | begin 70 | connect_internal 71 | rescue => ex 72 | send_local_event "pusher:error", ex 73 | end 74 | end 75 | else 76 | connect_internal 77 | end 78 | self 79 | end 80 | 81 | def disconnect 82 | return unless @connection 83 | logger.debug("Pusher : disconnecting") 84 | @connected = false 85 | @connection.close 86 | @connection = nil 87 | if @connection_thread 88 | @connection_thread.kill 89 | @connection_thread = nil 90 | end 91 | end 92 | 93 | def subscribe(channel_name, user_data = nil) 94 | if user_data.is_a? Hash 95 | user_data = user_data.to_json 96 | elsif user_data 97 | user_data = {:user_id => user_data}.to_json 98 | elsif is_presence_channel(channel_name) 99 | raise ArgumentError, "user_data is required for presence channels" 100 | end 101 | 102 | channel = @channels.add(channel_name, user_data) 103 | if @connected 104 | authorize(channel, method(:authorize_callback)) 105 | end 106 | return channel 107 | end 108 | 109 | def unsubscribe(channel_name) 110 | channel = @channels.remove channel_name 111 | if channel && @connected 112 | send_event('pusher:unsubscribe', { 113 | 'channel' => channel_name 114 | }) 115 | end 116 | return channel 117 | end 118 | 119 | def bind(event_name, &callback) 120 | @global_channel.bind(event_name, &callback) 121 | return self 122 | end 123 | 124 | def [](channel_name) 125 | @channels[channel_name] || NullChannel.new(channel_name) 126 | end 127 | 128 | def subscribe_all 129 | @channels.channels.clone.each { |k,v| subscribe(v.name, v.user_data) } 130 | end 131 | 132 | # auth for private and presence 133 | def authorize(channel, callback) 134 | if is_private_channel(channel.name) 135 | auth_data = get_private_auth(channel) 136 | elsif is_presence_channel(channel.name) 137 | auth_data = get_presence_auth(channel) 138 | end 139 | # could both be nil if didn't require auth 140 | callback.call(channel, auth_data, channel.user_data) 141 | end 142 | 143 | def authorize_callback(channel, auth_data, channel_data) 144 | send_event('pusher:subscribe', { 145 | 'channel' => channel.name, 146 | 'auth' => auth_data, 147 | 'channel_data' => channel_data 148 | }) 149 | channel.acknowledge_subscription(nil) 150 | end 151 | 152 | def is_private_channel(channel_name) 153 | channel_name.match(/^private-/) 154 | end 155 | 156 | def is_presence_channel(channel_name) 157 | channel_name.match(/^presence-/) 158 | end 159 | 160 | def get_private_auth(channel) 161 | return @auth_method.call(@socket_id, channel) if @auth_method 162 | 163 | string_to_sign = @socket_id + ':' + channel.name 164 | signature = hmac(@secret, string_to_sign) 165 | "#{@key}:#{signature}" 166 | end 167 | 168 | def get_presence_auth(channel) 169 | return @auth_method.call(@socket_id, channel) if @auth_method 170 | 171 | string_to_sign = @socket_id + ':' + channel.name + ':' + channel.user_data 172 | signature = hmac(@secret, string_to_sign) 173 | "#{@key}:#{signature}" 174 | end 175 | 176 | 177 | # for compatibility with JavaScript client API 178 | alias :subscribeAll :subscribe_all 179 | 180 | def send_event(event_name, data) 181 | payload = {'event' => event_name, 'data' => data}.to_json 182 | @connection.send(payload) 183 | logger.debug("Pusher : sending event : #{payload}") 184 | end 185 | 186 | def send_channel_event(channel, event_name, data) 187 | payload = {'channel' => channel, 'event' => event_name, 'data' => data}.to_json 188 | @connection.send(payload) 189 | logger.debug("Pusher : sending channel event : #{payload}") 190 | end 191 | 192 | protected 193 | 194 | attr_reader :logger 195 | 196 | def connect_internal 197 | @connection = PusherWebSocket.new(@url, { 198 | :ssl => @encrypted, 199 | :cert_file => @cert_file, 200 | :ssl_verify => @ssl_verify 201 | }) 202 | 203 | logger.debug("Websocket connected") 204 | 205 | loop do 206 | @connection.receive.each do |msg| 207 | params = parser(msg) 208 | 209 | # why ? 210 | next if params['socket_id'] && params['socket_id'] == self.socket_id 211 | 212 | send_local_event(params['event'], params['data'], params['channel']) 213 | end 214 | end 215 | end 216 | 217 | def send_local_event(event_name, event_data, channel_name=nil) 218 | if channel_name 219 | channel = @channels[channel_name] 220 | if channel 221 | channel.dispatch_with_all(event_name, event_data) 222 | end 223 | end 224 | 225 | @global_channel.dispatch_with_all(event_name, event_data) 226 | logger.debug("Pusher : event received : channel: #{channel_name}; event: #{event_name}") 227 | end 228 | 229 | def parser(data) 230 | return data if data.is_a? Hash 231 | return JSON.parse(data) 232 | rescue => err 233 | logger.warn(err) 234 | logger.warn("Pusher : data attribute not valid JSON - you may wish to implement your own Pusher::Client.parser") 235 | return data 236 | end 237 | 238 | def hmac(secret, string_to_sign) 239 | digest = OpenSSL::Digest::SHA256.new 240 | signature = OpenSSL::HMAC.hexdigest(digest, secret, string_to_sign) 241 | end 242 | end 243 | 244 | end 245 | --------------------------------------------------------------------------------